Compare commits
73 Commits
d45a35a4af
..
2
| Author | SHA1 | Date | |
|---|---|---|---|
| 65e05612a1 | |||
| 850ee99cb6 | |||
| b5b21d146a | |||
| ee0b9d8341 | |||
| 9d826a4e81 | |||
| db3c49099c | |||
| ddd9d076c1 | |||
| 7a47131f6f | |||
| 9196102f68 | |||
| 6b12dd2c5b | |||
| eed1ab9a17 | |||
| b27ac622b4 | |||
| 14b46087dd | |||
| 098c97c7bd | |||
| 4b8e5bb0bd | |||
| d75289ac56 | |||
| 05f7b8fd04 | |||
| 8f616f359f | |||
| 5ad972767d | |||
| 0fdc12695e | |||
| e68db11aaa | |||
| b0bbc57880 | |||
| 408caee261 | |||
| 86eb0632e0 | |||
| c42a6027a4 | |||
| b1ae8bbd20 | |||
| e3f20f07d9 | |||
| 9fcb2da75d | |||
| 88af57182f | |||
| b2d5684192 | |||
| 31c4b1cfc4 | |||
| 202d379f5a | |||
| a3f82f875d | |||
| 5fbe865a29 | |||
| 87742cf60b | |||
| 6c53e1c87d | |||
| 04276dec0e | |||
| 21bb9e6f8f | |||
| 1441b7da9f | |||
| 9ee942428d | |||
| 1dc5552f98 | |||
| 2e1387273a | |||
| a7d8cbe713 | |||
| 61f8205f40 | |||
| 294837daa0 | |||
| e192f58cd0 | |||
| f1cd2e3d42 | |||
| 135c6a419d | |||
| 18ae6e0619 | |||
| 9b37edd92e | |||
| df386ef875 | |||
| f4ce82957b | |||
| 9afa877e39 | |||
| 23da1f6498 | |||
| f5a551b2ae | |||
| 427ce8a572 | |||
| 9e12b22e85 | |||
| 227d14b7e4 | |||
| 65365da89c | |||
| 0faad45f3d | |||
| adcb8f54d6 | |||
| 3fde5c28e3 | |||
| f4caab940e | |||
| cad4d5fc7c | |||
| d5e3f2dcef | |||
| 5b4c8ceae8 | |||
| adab112b5b | |||
| e816d8d9d1 | |||
| 1e4c4d679f | |||
| 4c66947c25 | |||
| afdfb37e35 | |||
| 9e5d61707d | |||
| a3581abf84 |
@@ -0,0 +1,79 @@
|
|||||||
|
# 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: 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
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||||
|
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||||
|
#
|
||||||
|
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
|
||||||
|
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
|
||||||
|
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||||
|
# выполняется локально из исходников (или руками через `./gradlew
|
||||||
|
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||||
|
# отдельно от этого workflow.
|
||||||
|
#
|
||||||
|
# Требуемые Gitea Action Variables:
|
||||||
|
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
|
||||||
|
# Требуемые Gitea Action Secrets:
|
||||||
|
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
||||||
|
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: |
|
||||||
|
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
|
||||||
|
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
|
||||||
|
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
|
||||||
|
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
|
||||||
|
#
|
||||||
|
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
|
||||||
|
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
|
||||||
|
# хардкодит версию — берёт её из тега каждый раз.
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "Publishing version: ${VERSION}"
|
||||||
|
./gradlew \
|
||||||
|
"-Pversion=${VERSION}" \
|
||||||
|
"-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
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
*.iml
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
out/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
|
.cortexkit/
|
||||||
|
.veai/
|
||||||
|
|
||||||
|
# Runtime / test artifacts
|
||||||
|
agentik.db
|
||||||
|
agentik.db-shm
|
||||||
|
agentik.db-wal
|
||||||
|
memory-md/agentik-mem-*/
|
||||||
+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 |
|
||||||
|
|
||||||
|
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Совместимость с native (linuxX64) — чеклист
|
||||||
|
|
||||||
|
Цель: `:standalone` собирается и запускается как `linuxX64` executable (и потенциально
|
||||||
|
остальные нативные таргеты KMP). Текущее состояние — JVM-only executable на
|
||||||
|
`runJvm`-таске.
|
||||||
|
|
||||||
|
Формат: `- N. [ ]` — задача; `- N. [x]` — выполнена; `- N. [-]` — отменена/не нужна.
|
||||||
|
Решения (что выбрали и почему) — курсивом внизу пункта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блок 1. Подготовка (уже сделана или тривиальная)
|
||||||
|
|
||||||
|
- 1. [x] **Замена `java.util.UUID` на KMP-stdlib (`kotlin.uuid.Uuid`).** Helper `Ids.new(prefix)` в `standalone/src/commonMain/.../persistence/Ids.kt`, три call-site (`ChatAgent`, `ChatConversation`, `SqliteWorkingMemoryStore`). Коммит `e816d8d`.
|
||||||
|
- 2. [x] **HTTP-движок: `ktor-server-netty` → `ktor-server-cio`.** Netty — JVM-only; CIO — KMP (jvm + linuxX64 + ios/...). Каталог-эntry Netty оставлен в `libs.versions.toml` на случай отката. Коммит `e816d8d`.
|
||||||
|
|
||||||
|
## Блок 2. Библиотеки litert-kmp (наши)
|
||||||
|
|
||||||
|
- 3. [ ] **`litert-api`: добавить `linuxX64()` в `kotlin{}`.**
|
||||||
|
Сейчас только `androidTarget() + jvm()`. commonMain уже KMP-ready (только
|
||||||
|
`kotlinx-coroutines-core`). Объём: одна строка в `litert-api/build.gradle.kts`,
|
||||||
|
один `litert-api-linuxx64`-артефакт публикуется в Nexus.
|
||||||
|
- 4. [ ] **`litert-openai`: добавить `linuxX64()` в `kotlin{}`.**
|
||||||
|
Та же история. commonMain deps (`ktor-client-core`, `ktor-client-cio`,
|
||||||
|
`kotlinx-serialization-json`) — все KMP с готовыми linuxX64-артефактами.
|
||||||
|
Объём: одна строка + проверить, что Android-only фичи (если есть) изолированы
|
||||||
|
в `androidMain` (сейчас модуль не имеет `androidMain`-специфичного кода).
|
||||||
|
*Каталог-запись `litert-openai-jvm` → переименовать в `litert-openai`*
|
||||||
|
(чтобы Linux-таргет подтягивал `-linuxx64`-вариант автоматически, не нужно
|
||||||
|
ручного `when target`).
|
||||||
|
- 5. [-] **`litert-google`: linuxX64 — невозможно.** Upstream-блокер: Google
|
||||||
|
публикует `litertlm-android` (AAR с `.so`) и `litertlm-jvm` (fat-jar с `.so`
|
||||||
|
для linux/mac/win JVM), но НЕ нативного `litertlm-linuxx64`. Без правки
|
||||||
|
upstream LiteRT-LM KMP-враппер для linuxX64 не напишется. Варианты:
|
||||||
|
(a) выкинуть `litert-google` на linuxX64 (`backend=google` → отказ); (b)
|
||||||
|
альтернативный движок (llama.cpp через JNI/Kotlin/Native);
|
||||||
|
(c) upstream PR в `litertlm`. *Решение: (a) для v1 linuxX64 — упасть с
|
||||||
|
понятной ошибкой `LlmBackend.GOOGLE is not supported on this platform`
|
||||||
|
вместо reflection-fallback на JVM .so, который на linuxX64 не сработает бы.*
|
||||||
|
|
||||||
|
## Блок 3. Наш `:server` модуль
|
||||||
|
|
||||||
|
- 6. [x] **Переписать `:server` с `kotlin("jvm")` на `kotlin("multiplatform")`.**
|
||||||
|
Все 9 целей сборки (jvm + macosX64 + macosArm64 + iosX64 + iosArm64 +
|
||||||
|
iosSimulatorArm64 + linuxX64 + linuxArm64 + mingwX64) собираются
|
||||||
|
`./gradlew :server:assemble` без ошибок. Блокеров нет: Ktor
|
||||||
|
`ktor-server-{core,sse,cio,content-negotiation}` и `ktor-io` (для
|
||||||
|
`ByteWriteChannel.writeStringUtf8`) есть в KMP-вариантах под все
|
||||||
|
цели; `kotlin.time.Instant` уже использовался. Единственное
|
||||||
|
изменение по сравнению с JVM-версией: `respondTextWriter` (JVM-only)
|
||||||
|
→ `respondBytesWriter` + `writeStringUtf8` (KMP, тот же
|
||||||
|
`ByteWriteChannel` API).
|
||||||
|
Источники переехали `src/main/kotlin/...` → `src/commonMain/kotlin/...`.
|
||||||
|
`:standalone:jvmTest` 44/44 зелёные после миграции.
|
||||||
|
|
||||||
|
## Блок 4. Наш `:proto` модуль
|
||||||
|
|
||||||
|
- 7. [x] **`:proto`: убрать `kotlinx-datetime` api-dep, оставить `kotlin.time.Instant`.**
|
||||||
|
Источник уже был на `kotlin.time.Instant` (мигрирован ранее), но
|
||||||
|
`kotlinx-datetime` оставался в gradle как мёртвая `api`-зависимость.
|
||||||
|
Удалено:
|
||||||
|
- `api(libs.kotlinx.datetime)` из `proto/build.gradle.kts`
|
||||||
|
- `implementation(libs.kotlinx.datetime)` из `standalone/build.gradle.kts`
|
||||||
|
- `[versions] kotlinx-datetime` и `[libraries] kotlinx-datetime` из
|
||||||
|
`gradle/libs.versions.toml`
|
||||||
|
`kotlin.time.Instant` доступен во всех KMP-целях без opt-in.
|
||||||
|
`./gradlew :server:assemble` + `:standalone:jvmTest` 44/44 — зелёные.
|
||||||
|
|
||||||
|
## Блок 5. SQLDelight — нативный драйвер
|
||||||
|
|
||||||
|
- 8. [ ] **`sqlite-driver` (JVM/JDBC) → `native-driver` для linuxX64.**
|
||||||
|
В jvmMain: оставить `sqlite-driver` (JDBC over `java.sql.DriverManager`).
|
||||||
|
В linuxX64Main: добавить `native-driver` — KMP driver поверх cinterop
|
||||||
|
с `libsqlite3` (нужен `-lsqlite3` linker-флаг или `SQLiteBundle` из
|
||||||
|
`co.touchlab:sqliter` для бандлинга). Создать `SqliteStores`-
|
||||||
|
нейтральный интерфейс хранилищ (он уже есть в `commonMain`), разделить
|
||||||
|
`jvmMain/SqliteStores.kt` и `linuxX64Main/SqliteStoresNative.kt` —
|
||||||
|
оба реализуют один `commonMain`-интерфейс. Объём: ~150 строк
|
||||||
|
(новый `SqliteStoresNative.kt` + gradle wiring).
|
||||||
|
*Решение по бандлу: использовать `SQLiteBundle` из
|
||||||
|
`co.touchlab:sqliter` (KMP-обёртка, тянет свой libsqlite) — без
|
||||||
|
зависимости от системного libsqlite3, бинарь работает на любом linux.*
|
||||||
|
- 9. [ ] **Тесты SQLite переписать на commonTest + `expect`/`actual` обёртку.**
|
||||||
|
`inMemory()`-открытие в `native-driver` использует другой API (нет
|
||||||
|
JDBC `:memory:`). Объём: один `expect fun openInMemory()` в commonMain +
|
||||||
|
два `actual` (JDBC `jdbc:sqlite::memory:` / native-driver нативный API).
|
||||||
|
|
||||||
|
## Блок 6. Внешние либы (`a2a-server`) — наши
|
||||||
|
|
||||||
|
- 10. [x] **AGUI убран из проекта.** catalog-entries `agui-api`/`agui-client`/`agui-server`,
|
||||||
|
`[versions] agui` и `implementation(libs.agui.server)` в `:standalone`
|
||||||
|
удалены — AGUI больше не нужен (см. Memory #3675 и переписанный
|
||||||
|
`docs/ARCHITECTURE.md`). AGUI-фасад был stateless request/response,
|
||||||
|
а agentik переходит на свой stateful протокол `:proto` / `:server`.
|
||||||
|
- 11. [ ] **`a2a-server`: JVM-only → KMP.** Per Memory #3561 — JVM-only
|
||||||
|
(client/server; shared уже KMP). Без native-таргета весь стек A2A
|
||||||
|
недоступен на linuxX64. Объём: переписать на `multiplatform`,
|
||||||
|
выделить engine-нейтральную маршрутизацию. *Альтернатива: на linuxX64
|
||||||
|
не подключать a2a-server вообще (только `:server`-фасад). Решение
|
||||||
|
отложено — зависит от того, нужен ли нативный MCP-юзер-кейс вообще
|
||||||
|
подключаться к A2A.*
|
||||||
|
|
||||||
|
## Блок 7. `:standalone` — добавить native target
|
||||||
|
|
||||||
|
- 12. [ ] **`standalone/build.gradle.kts`: добавить `linuxX64()` и
|
||||||
|
`linuxX64Main`/`linuxX64Test` source-sets.** В `linuxX64Main`:
|
||||||
|
`:server` (после п.6), `litert-openai` (после п.4), `litert-api`
|
||||||
|
(после п.3), `ktor-server-cio` (engine), `ktor-client-cio`,
|
||||||
|
`kotlin-sdk-client` (MCP — KMP, готов), `kotlinx-io` (KMP, готов).
|
||||||
|
Исключить `litert-google` (п.5) и `a2a-server` (п.11) из linuxX64Main.
|
||||||
|
- 13. [ ] **`Main.kt`: разделить на `commonMain` (бизнес-логика: создание
|
||||||
|
ChatAgent, MCP, LlmConfig) + `jvmMain`/`linuxX64Main` (выбор engine,
|
||||||
|
embeddedServer).** embeddedServer(CIO, …) — один и тот же код на обеих
|
||||||
|
платформах, KMP-нейтрален. Главное отличие — `SqliteStores.open()` и
|
||||||
|
обработка GOOGLE-бэкенда на linuxX64 (fail-fast).
|
||||||
|
|
||||||
|
## Блок 8. Тесты и CI
|
||||||
|
|
||||||
|
- 14. [ ] **`linuxX64Test` source-set.** Покрыть те же 44 теста на
|
||||||
|
native: должен зелёный прогон через `./gradlew :standalone:linuxX64Test`.
|
||||||
|
SQLDelight-тесты на native требуют `libsqlite3`/`SQLiteBundle`
|
||||||
|
на машине (CI-linux предоставляет).
|
||||||
|
- 15. [ ] **Smoke e2e на linuxX64.** Запустить
|
||||||
|
`./gradlew :standalone:runLinuxX64` локально, проверить
|
||||||
|
`POST /agentik/conversations` → 201, цикл с реальным LLM через OpenAI
|
||||||
|
backend; убедиться что LiteRT-LM GOOGLE не подключается (отказ с
|
||||||
|
понятной ошибкой).
|
||||||
|
|
||||||
|
## Блок 9. Дистрибуция native-бинаря
|
||||||
|
|
||||||
|
- 16. [ ] **`runLinuxX64`-таска через KMP binaries DSL.** После добавления
|
||||||
|
`linuxX64()` KMP генерирует её автоматически — нужно только убедиться
|
||||||
|
что `binaries { executable { mainClass.set("pw.binom.agentik.standalone.MainKt") } }`
|
||||||
|
работает для linuxX64 (по аналогии с уже настроенным `runJvm`).
|
||||||
|
- 17. [ ] **Дистрибутив `.tar`/`.zip`/`AppImage/Docker`.** KMP
|
||||||
|
`assembleDist` для linuxX64 делает tar.gz/zip. Для прод-дистрибуции —
|
||||||
|
Docker-образ (`FROM gcr.io/distroless/cc`) или AppImage. Объём:
|
||||||
|
~50 строк Dockerfile + GitHub Action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ блокирует linuxX64 (закрыто или тривиально)
|
||||||
|
|
||||||
|
- `kotlinx-coroutines-core` — KMP linuxX64 ✓
|
||||||
|
- `kotlinx-serialization-json` — KMP linuxX64 ✓
|
||||||
|
- `kotlinx-io` — KMP linuxX64 (используется в MCP stdio-transport) ✓
|
||||||
|
- `kotlin-sdk-client` (MCP) — KMP full targets ✓
|
||||||
|
- `ktor-server-core`, `ktor-server-sse`, `ktor-server-cio` — KMP linuxX64 ✓
|
||||||
|
- `ktor-client-core`, `ktor-client-cio` — KMP linuxX64 ✓
|
||||||
|
- `pw.binom.uuid` — **отсутствует** в проекте (используем `kotlin.uuid`) ✓
|
||||||
|
|
||||||
|
## Жёсткие блокеры (не обойти без upstream-работы)
|
||||||
|
|
||||||
|
- `litertlm-jvm` → нужен `litertlm-native` от Google (см. п.5)
|
||||||
|
- `a2a-server` JVM-only (см. п.11) — нужно переписать или явно не подключать
|
||||||
@@ -1,2 +1,153 @@
|
|||||||
# 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 one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||||
|
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||||
|
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||||
|
(см. "Модули" ниже).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### 1. Скачать fatjar
|
||||||
|
|
||||||
|
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||||
|
tag-релизах, либо соберите из исходников:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.binom.pw/subochev/agentik
|
||||||
|
cd agentik
|
||||||
|
./gradlew :standalone:shadowJar
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||||
|
зависит от LLM-backend'а).
|
||||||
|
|
||||||
|
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=openai \
|
||||||
|
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||||
|
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||||
|
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||||
|
```
|
||||||
|
|
||||||
|
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||||
|
|
||||||
|
## Подключиться
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
- Запускаемые:
|
||||||
|
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||||
|
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
|
||||||
|
- Библиотеки (контракты и реализации):
|
||||||
|
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||||
|
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||||
|
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||||
|
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||||
|
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||||
|
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||||
|
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||||
|
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||||
|
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||||
|
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||||
|
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||||
|
|
||||||
|
## Где смотреть версии
|
||||||
|
|
||||||
|
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||||
|
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||||
|
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||||
|
|
||||||
|
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||||
|
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||||
|
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||||
|
тега и публикует.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
|
||||||
|
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||||
|
Параметры через:
|
||||||
|
|
||||||
|
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||||
|
- `binom.repo.user`
|
||||||
|
- `binom.repo.password`
|
||||||
|
|
||||||
|
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
|
||||||
|
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
|
||||||
|
репозитория). Plain-HTTP Nexus требует
|
||||||
|
`setAllowInsecureProtocol(true)` — уже включено в
|
||||||
|
`settings.gradle.kts`.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||||
|
|
||||||
|
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||||
|
shadowjar'ов.
|
||||||
|
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||||
|
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||||
|
|
||||||
|
## Что отличает от других агентских фреймворков
|
||||||
|
|
||||||
|
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||||
|
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||||
|
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||||
|
Один fatjar — три API.
|
||||||
|
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||||
|
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||||
|
Desktop / CLI.
|
||||||
|
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||||
|
потери контекста, если пользователь нажал Ctrl-C во время
|
||||||
|
долгого tool-вызова.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Участие в проекте
|
||||||
|
|
||||||
|
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||||
|
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||||
|
изменении API.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Ядро системы tools для LLM-агента:
|
||||||
|
|
||||||
|
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||||
|
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
|
||||||
|
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
|
||||||
|
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
|
||||||
|
→ запускает → возвращает результат.
|
||||||
|
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
|
||||||
|
вызов, помечая результат `[cancelled by user]`.
|
||||||
|
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
|
||||||
|
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
|
||||||
|
threadpool от переполнения при fan-out 30+ диалогов.
|
||||||
|
|
||||||
|
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||||
|
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||||
|
IM-фронтендах (CLI, TUI, IRC, web).
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||||
|
(memory / skills / files / web), фильтрует через
|
||||||
|
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyToolset : Toolset {
|
||||||
|
override val name = "my"
|
||||||
|
override val description = "Custom user-defined tools"
|
||||||
|
override val tools = listOf(myTool1, myTool2)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dispatcher = ToolDispatcher(
|
||||||
|
toolsets = listOf(MemoryTools(memory), MyToolset()),
|
||||||
|
enabled = setOf("memory", "my"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
|
||||||
|
|
||||||
|
## Как пишется tool
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data object EchoTool : Tool {
|
||||||
|
override val name = "echo"
|
||||||
|
override val description = "Echoes back the argument"
|
||||||
|
override val argsSchema = jsonSchema {
|
||||||
|
property("text", JsonType.STRING) { required = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun invoke(args: JsonObject): ToolResult {
|
||||||
|
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
|
||||||
|
return ToolResult.Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agent-toolsets:allTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||||
|
budget exhaustion, registry filter, parallel dispatch.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
|
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||||
|
(см. `:storage-core`).
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Реализует полную спецификацию из
|
||||||
|
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||||
|
log, rolling buffer, partial-state persistence.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// KMP-модуль с ядром механики toolsets: реестр, диспетчер, встроенные тулы
|
||||||
|
// enable_toolset/disable_toolset. Не зависит от :standalone — может быть
|
||||||
|
// переиспользован в Android-сборке и в любом другом LiteTool-агенте.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
||||||
|
api(project(":storage-core"))
|
||||||
|
|
||||||
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
|
api(libs.litert.api)
|
||||||
|
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
// runBlocking для SyncLiteTool обёртки (LiteTool.invoke — sync)
|
||||||
|
implementation(libs.kotlin.logging)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Встроенный тул `disable_toolset` — обратная операция к [EnableToolsetTool].
|
||||||
|
*
|
||||||
|
* Контракт (зафиксирован в дизайн-доке):
|
||||||
|
* - `(member, active)` → `"Toolset 'X' deactivated."`
|
||||||
|
* - `(member, inactive)` → `"Toolset 'X' deactivated."` (единообразно — как будто был активен)
|
||||||
|
* - `(unknown, actives exist)` → `"Toolset 'X' not found. Available for deactivation: a, b."`
|
||||||
|
* - `(unknown, no actives)` → `"Toolset 'X' not found. No toolsets to deactivate."`
|
||||||
|
*
|
||||||
|
* Семантика "единообразно как будто был активен" выбрана потому что модель не
|
||||||
|
* должна различать "он и так был выключен" и "я его выключил" — оба ответа
|
||||||
|
* означают "сейчас выключен".
|
||||||
|
*/
|
||||||
|
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
|
val tool: LiteTool = syncLiteTool(
|
||||||
|
describeJson = DESCRIBE,
|
||||||
|
handler = ::invoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal suspend fun invoke(args: String): String {
|
||||||
|
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||||
|
val toolset = registry.findByName(name)
|
||||||
|
if (toolset != null) {
|
||||||
|
// Единообразный ответ независимо от текущего состояния.
|
||||||
|
registry.deactivate(name)
|
||||||
|
return "Toolset '$name' deactivated."
|
||||||
|
}
|
||||||
|
// Неизвестный — перечисляем активные (что можно деактивировать)
|
||||||
|
val actives = registry.activeNames()
|
||||||
|
return if (actives.isEmpty()) {
|
||||||
|
"Toolset '$name' not found. No toolsets to deactivate."
|
||||||
|
} else {
|
||||||
|
"Toolset '$name' not found. Available for deactivation: ${actives.joinToString(", ")}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME: String = "disable_toolset"
|
||||||
|
|
||||||
|
internal val DESCRIBE: String = """
|
||||||
|
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Встроенный тул `enable_toolset` — модель может им активировать любой
|
||||||
|
* зарегистрированный тулсет.
|
||||||
|
*
|
||||||
|
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
|
||||||
|
* - `(member, inactive)` → `"Toolset 'X' activated."`
|
||||||
|
* - `(member, active)` → `"Toolset 'X' already active."`
|
||||||
|
* - `(unknown, inactives exist)` → `"Toolset 'X' not found. Available: a, b."`
|
||||||
|
* - `(unknown, all active)` → `"Toolset 'X' not found. No toolsets available for activation."`
|
||||||
|
*
|
||||||
|
* Идемпотентен: повторный enable того же тулсета возвращает
|
||||||
|
* `"already active"` без сайд-эффектов (поле state не меняется).
|
||||||
|
*/
|
||||||
|
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
|
val tool: LiteTool = syncLiteTool(
|
||||||
|
describeJson = DESCRIBE,
|
||||||
|
handler = ::invoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal suspend fun invoke(args: String): String {
|
||||||
|
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||||
|
val toolset = registry.findByName(name)
|
||||||
|
if (toolset != null) {
|
||||||
|
val wasActive = registry.isActive(name)
|
||||||
|
registry.activate(name)
|
||||||
|
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||||
|
}
|
||||||
|
// Неизвестный — перечисляем доступные к активации (inactives)
|
||||||
|
val inactives = registry.inactiveNames()
|
||||||
|
return if (inactives.isEmpty()) {
|
||||||
|
"Toolset '$name' not found. No toolsets available for activation."
|
||||||
|
} else {
|
||||||
|
"Toolset '$name' not found. Available: ${inactives.joinToString(", ")}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NAME: String = "enable_toolset"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
|
||||||
|
* Соответствует litert-kmp формату LiteTool.describe().
|
||||||
|
*/
|
||||||
|
internal val DESCRIBE: String = """
|
||||||
|
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
|
||||||
|
* Возвращает null если отсутствует или не строка.
|
||||||
|
*/
|
||||||
|
internal fun parseName(argsJson: String): String? = runCatching {
|
||||||
|
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
|
||||||
|
}.getOrNull()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
||||||
|
*
|
||||||
|
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||||
|
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||||
|
* неудобство для тулов с асинхронной работой (DB, сеть).
|
||||||
|
*
|
||||||
|
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||||
|
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||||
|
*
|
||||||
|
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||||
|
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||||
|
*/
|
||||||
|
internal class SyncLiteTool(
|
||||||
|
private val describeJson: String,
|
||||||
|
private val handler: suspend (String) -> String,
|
||||||
|
) : LiteTool {
|
||||||
|
override fun describe(): String = describeJson
|
||||||
|
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
||||||
|
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
||||||
|
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
||||||
|
*/
|
||||||
|
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
|
||||||
|
SyncLiteTool(describeJson, handler)
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown-секция для system prompt, описывающая доступные тулсеты.
|
||||||
|
*
|
||||||
|
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
|
||||||
|
* - **Если toolsets пустой** → `null` (секция не добавляется, агент не знает
|
||||||
|
* о механике toolsets вообще; тулы enable_toolset/disable_toolset тоже
|
||||||
|
* не регистрируются — полная невидимость).
|
||||||
|
* - **Иначе** → короткое описание концепции + список всех тулсетов
|
||||||
|
* в формате `name — description`, активные и неактивные одинаково
|
||||||
|
* (модель видит за что каждый отвечает).
|
||||||
|
*
|
||||||
|
* Auto-activation НЕ упоминается в prompt — только в dispatch (если модель
|
||||||
|
* случайно вызвала тул из выключенного тулсета, диспетчер сам активирует).
|
||||||
|
* Это чтобы не давать модели ложную опцию "не буду enable, а просто вызову".
|
||||||
|
*/
|
||||||
|
object SystemPromptToolsetSection {
|
||||||
|
|
||||||
|
fun render(
|
||||||
|
active: List<ToolsetContribution>,
|
||||||
|
inactive: List<ToolsetContribution>,
|
||||||
|
): String? {
|
||||||
|
if (active.isEmpty() && inactive.isEmpty()) return null
|
||||||
|
return buildString {
|
||||||
|
appendLine("## Toolsets")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Toolsets group related tools. Use enable_toolset to activate one; its tools become available. Use disable_toolset to deactivate.")
|
||||||
|
appendLine()
|
||||||
|
if (active.isNotEmpty()) {
|
||||||
|
appendLine("Active:")
|
||||||
|
for (c in active) appendLine("- ${c.name} — ${c.description}")
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
if (inactive.isNotEmpty()) {
|
||||||
|
appendLine("Inactive:")
|
||||||
|
for (c in inactive) appendLine("- ${c.name} — ${c.description}")
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: рендер по [ToolsetRegistry] (синхронный — без activeTools(),
|
||||||
|
* только имена и описания).
|
||||||
|
*/
|
||||||
|
fun render(registry: ToolsetRegistry, activeNames: Set<String>): String? {
|
||||||
|
val all = registry.all()
|
||||||
|
if (all.isEmpty()) return null
|
||||||
|
val active = all.filter { it.name in activeNames }
|
||||||
|
val inactive = all.filter { it.name !in activeNames }
|
||||||
|
return render(active, inactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст, который тулсеты получают при активации.
|
||||||
|
*
|
||||||
|
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||||
|
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||||
|
* могли читать/писать сообщения и память.
|
||||||
|
*
|
||||||
|
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||||
|
* параметризованный factory и принимать остальное извне — [ToolsetContext]
|
||||||
|
* остаётся минимальным ядром.
|
||||||
|
*/
|
||||||
|
interface ToolsetContext {
|
||||||
|
val logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op логгер по умолчанию. Передаётся в [ToolsetRegistry], если внешний код
|
||||||
|
* не предоставил свой (например, в тестах или при работе из CLI без logging
|
||||||
|
* конфигурации).
|
||||||
|
*/
|
||||||
|
object NoOpLogger : Logger {
|
||||||
|
override fun debug(msg: String) {}
|
||||||
|
override fun info(msg: String) {}
|
||||||
|
override fun warn(msg: String) {}
|
||||||
|
override fun error(msg: String, ex: Throwable?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный logger-интерфейс для тулсетов. Совместим по сигнатуре с
|
||||||
|
* `kotlin-logging`'s `KLogger` и `org.slf4j.Logger` — внешний код может
|
||||||
|
* передать адаптер из любого.
|
||||||
|
*/
|
||||||
|
interface Logger {
|
||||||
|
fun debug(msg: String)
|
||||||
|
fun info(msg: String)
|
||||||
|
fun warn(msg: String)
|
||||||
|
fun error(msg: String, ex: Throwable? = null)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Декларация одного тулсета: имя, описание (видимое модели в system prompt),
|
||||||
|
* и список входящих тулов.
|
||||||
|
*
|
||||||
|
* Toolset — это группа инструментов, которые модель может включить или
|
||||||
|
* выключить через `enable_toolset` / `disable_toolset`. Модель не получает
|
||||||
|
* тулы неактивного тулсета напрямую; если она случайно вызовет тул из
|
||||||
|
* выключенного тулсета, диспетчер молча его включает (прощающая семантика).
|
||||||
|
*
|
||||||
|
* @property name уникальное имя тулсета (например, `"media"`).
|
||||||
|
* @property description короткое описание что тулсет делает; показывается в
|
||||||
|
* system prompt чтобы модель могла решить, какой тулсет включить.
|
||||||
|
* @property tools список [LiteTool]-ов, которые становятся доступны когда
|
||||||
|
* тулсет активен. У каждого тула `toolName` используется для поиска владельца
|
||||||
|
* при диспетчеризации.
|
||||||
|
*/
|
||||||
|
data class ToolsetContribution(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val tools: List<ToolEntry>,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Один инструмент в составе тулсета.
|
||||||
|
*
|
||||||
|
* @property toolName стабильное имя тула (должно совпадать с `name` полем
|
||||||
|
* в JSON-дескрипторе тула, иначе диспетчер его не найдёт).
|
||||||
|
* @property tool сам [LiteTool] — синхронный интерфейс litert-kmp.
|
||||||
|
*/
|
||||||
|
data class ToolEntry(val toolName: String, val tool: LiteTool)
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип диспетчера "плоских" тулов (не из тулсетов). Принимает имя тула и
|
||||||
|
* сырые JSON-аргументы строкой, возвращает результат строкой.
|
||||||
|
*
|
||||||
|
* Используется [ToolsetDispatchPolicy] как fallback: если тул не найден ни в
|
||||||
|
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
|
||||||
|
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
|
||||||
|
* toolsets в одном агенте.
|
||||||
|
*
|
||||||
|
* **Не-suspend контракт:** baseDispatcher должен быть быстрым (просто
|
||||||
|
* разрезолвить имя тула и вызвать LiteTool.invoke). Если wrapper'у нужен
|
||||||
|
* реальный suspending I/O — он может сам обернуть в `withContext(...)`.
|
||||||
|
* Внутри [ToolsetDispatchPolicy.dispatch] весь invoke уже обёрнут в
|
||||||
|
* `runInterruptible(coroutineContext)` — Job.cancel() в caller'е приведёт к
|
||||||
|
* Thread.interrupt() на блокирующем треде.
|
||||||
|
*/
|
||||||
|
typealias BaseToolDispatcher = (toolName: String, argumentsJson: String) -> String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диспетчер вызовов тулов с учётом тулсетов.
|
||||||
|
*
|
||||||
|
* Алгоритм при вызове `dispatch(toolName, args)`:
|
||||||
|
* 1. **Активный тул** — тул с таким именем есть в одном из активных тулсетов.
|
||||||
|
* Выполняем напрямую, возвращаем результат. Outcome: `Ran`.
|
||||||
|
* 2. **Неактивный тул** — тул принадлежит зарегистрированному (но неактивному)
|
||||||
|
* тулсету. Молча активируем тулсет, выполняем тул. Outcome: `Ran`.
|
||||||
|
* 3. **Неизвестный тул** — нет ни в одном тулсете. Передаём в [baseDispatcher]
|
||||||
|
* (там живут плоские тулы вроде `memory_save`). Outcome: `Ran` или `Failed`
|
||||||
|
* — зависит от того, что вернёт base.
|
||||||
|
*
|
||||||
|
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
|
||||||
|
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
|
||||||
|
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
|
||||||
|
*
|
||||||
|
* **Cancellation semantics.** Все три пути выполняют `tool.invoke(...)` через
|
||||||
|
* [runInterruptible] — если вызвавший корутин (например, sub-Job в ChatConversation)
|
||||||
|
* был отменён через `Job.cancel()`, реальный блокирующий поток получит
|
||||||
|
* `Thread.interrupt()` → cooperative тулы (`Thread.sleep`, blocking I/O с
|
||||||
|
* timeout, и т.п.) могут прервать своё выполнение.
|
||||||
|
*/
|
||||||
|
class ToolsetDispatchPolicy(
|
||||||
|
private val registry: ToolsetRegistry,
|
||||||
|
private val baseDispatcher: BaseToolDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
sealed interface Outcome {
|
||||||
|
/** Тул выполнен успешно. */
|
||||||
|
data class Ran(
|
||||||
|
val toolsetName: String?,
|
||||||
|
val toolName: String,
|
||||||
|
val result: String,
|
||||||
|
) : Outcome
|
||||||
|
/** Тул не найден ни в одном тулсете, и base dispatcher его тоже не знает. */
|
||||||
|
data class Unknown(val toolName: String, val reason: String) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
|
||||||
|
// Захватываем Job один раз — если он отменён к моменту invoke (или во
|
||||||
|
// время invoke), мы сможем прервать LiteTool через обычный механизм
|
||||||
|
// cooperative cancellation (tool внутри себя делает Thread.sleep → реагирует
|
||||||
|
// на Thread.interrupt). Job.cancel() из ChatConversation interrupt()
|
||||||
|
// кооперативно прерывает LiteConv-стрим; чтобы прервать именно tool,
|
||||||
|
// ChatConversation прибивает currentToolJob через sub-Job (runInterruptible
|
||||||
|
// там не работает, но suite достаточно для типовых нагрузок).
|
||||||
|
val currentJob = currentCoroutineContext()[Job]
|
||||||
|
// 1. Активный тул?
|
||||||
|
val activeTools = registry.activeTools()
|
||||||
|
val activeToolNames = activeTools.map { it.nameFromDescribe() }
|
||||||
|
if (toolName in activeToolNames) {
|
||||||
|
val tool = activeTools.first { it.nameFromDescribe() == toolName }
|
||||||
|
currentJob?.cancelIfAlreadyCancelled()
|
||||||
|
val result = tool.invoke(argumentsJson)
|
||||||
|
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Принадлежит зарегистрированному тулсету (auto-activate)?
|
||||||
|
val ownerPair = registry.findOwnerByToolName(toolName)
|
||||||
|
if (ownerPair != null) {
|
||||||
|
val (contribution, entry) = ownerPair
|
||||||
|
registry.activate(contribution.name)
|
||||||
|
currentJob?.cancelIfAlreadyCancelled()
|
||||||
|
val result = entry.tool.invoke(argumentsJson)
|
||||||
|
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback — плоский тул вне toolsets.
|
||||||
|
val result = baseDispatcher(toolName, argumentsJson)
|
||||||
|
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Job.cancelIfAlreadyCancelled() {
|
||||||
|
if (isCancelled) throw kotlin.coroutines.cancellation.CancellationException("job cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun findActiveToolsetForTool(toolName: String): String? {
|
||||||
|
val active = registry.activeNames()
|
||||||
|
for (name in active) {
|
||||||
|
val contribution = registry.findByName(name) ?: continue
|
||||||
|
if (contribution.tools.any { it.toolName == toolName }) return name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает имя тула из его JSON-дескриптора. LiteTool — стандартизированный
|
||||||
|
* формат (см. litert-kmp LiteTool), где JSON содержит поле `"name"`.
|
||||||
|
*
|
||||||
|
* Используется для матчинга имени тула (которое модель передаёт в
|
||||||
|
* `tool_calls`) с фактическим LiteTool-ом (у которого имени нет в API).
|
||||||
|
*
|
||||||
|
* При ошибке парсинга возвращает пустую строку — диспетчер просто не найдёт
|
||||||
|
* такой тул, что безопасно (уйдёт в fallback).
|
||||||
|
*/
|
||||||
|
internal fun LiteTool.nameFromDescribe(): String {
|
||||||
|
val json = runCatching { describe() }.getOrNull() ?: return ""
|
||||||
|
return runCatching {
|
||||||
|
kotlinx.serialization.json.Json.parseToJsonElement(json)
|
||||||
|
.jsonObject["name"]?.jsonPrimitive?.content ?: ""
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val kotlinx.serialization.json.JsonElement.jsonObject
|
||||||
|
get() = (this as kotlinx.serialization.json.JsonObject)
|
||||||
|
private val kotlinx.serialization.json.JsonElement.jsonPrimitive
|
||||||
|
get() = (this as kotlinx.serialization.json.JsonPrimitive)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр тулсетов: хранит список доступных [ToolsetContribution]-ов и
|
||||||
|
* отслеживает, какие из них сейчас активны.
|
||||||
|
*
|
||||||
|
* Потокобезопасен (`Mutex` вокруг всех мутаций). Один экземпляр на агента —
|
||||||
|
* разделяется между ChatAgent и диспетчером.
|
||||||
|
*
|
||||||
|
* Диспетчер тулов (см. [ToolsetDispatchPolicy]) использует [findOwnerByToolName]
|
||||||
|
* чтобы:
|
||||||
|
* 1. Найти активный тул по имени — диспетчировать напрямую.
|
||||||
|
* 2. Если тул принадлежит неактивному тулсету — молча его активировать.
|
||||||
|
* 3. Если тул вообще не найден — передать в fallback-диспетчер
|
||||||
|
* (для «плоских» тулов вне toolsets).
|
||||||
|
*
|
||||||
|
* Модель может явно управлять состоянием через тулы `enable_toolset` /
|
||||||
|
* `disable_toolset` (см. [EnableToolsetTool], [DisableToolsetTool]).
|
||||||
|
*/
|
||||||
|
class ToolsetRegistry(
|
||||||
|
private val contributions: List<ToolsetContribution>,
|
||||||
|
private val context: ToolsetContext = NoOpToolsetContext,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val active: MutableSet<String> = mutableSetOf()
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
|
/** Все зарегистрированные тулсеты (read-only). */
|
||||||
|
fun all(): List<ToolsetContribution> = contributions
|
||||||
|
|
||||||
|
/** Имена всех зарегистрированных тулсетов (для prompt section и диагностики). */
|
||||||
|
fun names(): List<String> = contributions.map { it.name }
|
||||||
|
|
||||||
|
/** Найти тулсет по имени (или null). */
|
||||||
|
fun findByName(name: String): ToolsetContribution? =
|
||||||
|
contributions.firstOrNull { it.name == name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти тулсет, владеющий тулом с данным именем. Перебирает все
|
||||||
|
* зарегистрированные тулсеты, у каждого смотрит [ToolsetContribution.tools].
|
||||||
|
*
|
||||||
|
* Используется диспетчером для auto-activation: если модель вызвала тул из
|
||||||
|
* неактивного тулсета — мы молча его активируем и выполняем.
|
||||||
|
*/
|
||||||
|
fun findOwnerByToolName(toolName: String): Pair<ToolsetContribution, ToolsetContribution.ToolEntry>? {
|
||||||
|
for (c in contributions) {
|
||||||
|
val entry = c.tools.firstOrNull { it.toolName == toolName }
|
||||||
|
if (entry != null) return c to entry
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isActive(name: String): Boolean = lock.withLock { active.contains(name) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активировать тулсет. Если уже активен — no-op. Возвращает `true`, если
|
||||||
|
* состояние изменилось (т.е. тулсет был неактивен и теперь активен).
|
||||||
|
*/
|
||||||
|
suspend fun activate(name: String): Boolean = lock.withLock {
|
||||||
|
active.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Деактивировать тулсет. Если и так неактивен — no-op. Возвращает `true`,
|
||||||
|
* если состояние изменилось.
|
||||||
|
*/
|
||||||
|
suspend fun deactivate(name: String): Boolean = lock.withLock {
|
||||||
|
active.remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun activeNames(): List<String> = lock.withLock { active.toList() }
|
||||||
|
|
||||||
|
suspend fun inactiveNames(): List<String> = lock.withLock {
|
||||||
|
contributions.map { it.name }.filter { it !in active }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список всех активных тулов (для передачи в LiteConversationConfig.tools).
|
||||||
|
* Вызывает `LiteTool.describe()` каждого тула — безопасно для типичных
|
||||||
|
* stateless тулов.
|
||||||
|
*/
|
||||||
|
suspend fun activeTools(): List<LiteTool> {
|
||||||
|
val activeNames = activeNames()
|
||||||
|
return activeNames.mapNotNull { name ->
|
||||||
|
val contribution = findByName(name)
|
||||||
|
contribution?.tools?.map { it.tool }
|
||||||
|
}.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тулсет-контекст, который передан конструктору. */
|
||||||
|
fun context(): ToolsetContext = context
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// no-op: нет внешних ресурсов. Сделано для удобства AutoCloseable-конвенции.
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Пустой реестр без единого тулсета. */
|
||||||
|
fun empty(context: ToolsetContext = NoOpToolsetContext): ToolsetRegistry =
|
||||||
|
ToolsetRegistry(emptyList(), context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дефолтный контекст для случая, когда внешний код не передал свой. Использует
|
||||||
|
* no-op логгер — события тулсетов (activate/deactivate/auto-activate) не
|
||||||
|
* пишутся никуда. Для prod-запуска передайте контекст с настоящим логгером.
|
||||||
|
*/
|
||||||
|
private val NoOpToolsetContext = object : ToolsetContext {
|
||||||
|
override val logger: Logger = NoOpLogger
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class DisableToolsetToolTest {
|
||||||
|
|
||||||
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
|
override fun invoke(arguments: String) = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun harness(toolsets: List<ToolsetContribution>): Pair<DisableToolsetTool, ToolsetRegistry> {
|
||||||
|
val r = ToolsetRegistry(toolsets)
|
||||||
|
return DisableToolsetTool(r) to r
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `describe contains expected name and parameters`() {
|
||||||
|
val (disable, _) = harness(emptyList())
|
||||||
|
val desc = disable.tool.describe()
|
||||||
|
assertEquals(true, desc.contains("\"name\":\"disable_toolset\""))
|
||||||
|
assertEquals(true, desc.contains("\"parameters\""))
|
||||||
|
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivating an active toolset returns deactivated message`() = runTest {
|
||||||
|
val (disable, reg) = harness(listOf(
|
||||||
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
|
))
|
||||||
|
reg.activate("media")
|
||||||
|
val r = disable.invoke("""{"name":"media"}""")
|
||||||
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
|
assertEquals(false, reg.isActive("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivating an inactive toolset returns the same uniform message`() = runTest {
|
||||||
|
val (disable, _) = harness(listOf(
|
||||||
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
|
))
|
||||||
|
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
||||||
|
val r = disable.invoke("""{"name":"media"}""")
|
||||||
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivating unknown toolset with actives returns available actives`() = runTest {
|
||||||
|
val (disable, reg) = harness(listOf(
|
||||||
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
|
ToolsetContribution("b", "y", emptyList()),
|
||||||
|
))
|
||||||
|
reg.activate("a")
|
||||||
|
reg.activate("b")
|
||||||
|
val r = disable.invoke("""{"name":"unknown"}""")
|
||||||
|
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivating unknown toolset with no actives returns no-toolsets message`() = runTest {
|
||||||
|
val (disable, _) = harness(listOf(
|
||||||
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
|
))
|
||||||
|
val r = disable.invoke("""{"name":"unknown"}""")
|
||||||
|
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing name argument returns error message`() = runTest {
|
||||||
|
val (disable, _) = harness(emptyList())
|
||||||
|
val r = disable.invoke("""{}""")
|
||||||
|
assertEquals("missing required argument 'name'", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class EnableToolsetToolTest {
|
||||||
|
|
||||||
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
|
override fun invoke(arguments: String) = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun harness(toolsets: List<ToolsetContribution>): Pair<EnableToolsetTool, ToolsetRegistry> {
|
||||||
|
val r = ToolsetRegistry(toolsets)
|
||||||
|
return EnableToolsetTool(r) to r
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `describe contains expected name and parameters`() {
|
||||||
|
val (enable, _) = harness(emptyList())
|
||||||
|
val desc = enable.tool.describe()
|
||||||
|
assertEquals(true, desc.contains("\"name\":\"enable_toolset\""))
|
||||||
|
assertEquals(true, desc.contains("\"parameters\""))
|
||||||
|
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activating a registered toolset returns activated message`() = runTest {
|
||||||
|
val (enable, reg) = harness(listOf(
|
||||||
|
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
||||||
|
))
|
||||||
|
val r = enable.invoke("""{"name":"media"}""")
|
||||||
|
assertEquals("Toolset 'media' activated.", r)
|
||||||
|
assertEquals(true, reg.isActive("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activating an already active toolset returns already-active message`() = runTest {
|
||||||
|
val (enable, reg) = harness(listOf(
|
||||||
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
|
))
|
||||||
|
reg.activate("media")
|
||||||
|
val r = enable.invoke("""{"name":"media"}""")
|
||||||
|
assertEquals("Toolset 'media' already active.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activating unknown toolset lists available inactives`() = runTest {
|
||||||
|
val (enable, _) = harness(listOf(
|
||||||
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
|
ToolsetContribution("b", "y", emptyList()),
|
||||||
|
ToolsetContribution("c", "z", emptyList()),
|
||||||
|
))
|
||||||
|
val r = enable.invoke("""{"name":"unknown"}""")
|
||||||
|
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activating unknown toolset with no inactives returns no-toolsets message`() = runTest {
|
||||||
|
val (enable, reg) = harness(listOf(
|
||||||
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
|
))
|
||||||
|
reg.activate("a")
|
||||||
|
val r = enable.invoke("""{"name":"unknown"}""")
|
||||||
|
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing name argument returns error message`() = runTest {
|
||||||
|
val (enable, _) = harness(emptyList())
|
||||||
|
val r = enable.invoke("""{}""")
|
||||||
|
assertEquals("missing required argument 'name'", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
class SystemPromptToolsetSectionTest {
|
||||||
|
|
||||||
|
private fun stub(name: String, desc: String): ToolsetContribution = ToolsetContribution(
|
||||||
|
name = name,
|
||||||
|
description = desc,
|
||||||
|
tools = emptyList(), // prompt section не зависит от tools
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty lists return null - section is omitted entirely`() {
|
||||||
|
assertNull(SystemPromptToolsetSection.render(emptyList(), emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only active present - omits inactive header`() {
|
||||||
|
val section = SystemPromptToolsetSection.render(
|
||||||
|
active = listOf(stub("a", "first toolset")),
|
||||||
|
inactive = emptyList(),
|
||||||
|
)
|
||||||
|
assertEquals(true, section!!.contains("## Toolsets"))
|
||||||
|
assertEquals(true, section.contains("Active:"))
|
||||||
|
assertEquals(true, section.contains("- a — first toolset"))
|
||||||
|
assertEquals(false, section.contains("Inactive:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only inactive present - omits active header`() {
|
||||||
|
val section = SystemPromptToolsetSection.render(
|
||||||
|
active = emptyList(),
|
||||||
|
inactive = listOf(stub("b", "second toolset")),
|
||||||
|
)
|
||||||
|
assertEquals(true, section!!.contains("## Toolsets"))
|
||||||
|
assertEquals(true, section.contains("Inactive:"))
|
||||||
|
assertEquals(true, section.contains("- b — second toolset"))
|
||||||
|
assertEquals(false, section.contains("\nActive:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both active and inactive - renders both blocks`() {
|
||||||
|
val section = SystemPromptToolsetSection.render(
|
||||||
|
active = listOf(stub("a", "first")),
|
||||||
|
inactive = listOf(stub("b", "second"), stub("c", "third")),
|
||||||
|
)
|
||||||
|
assertEquals(true, section!!.contains("- a — first"))
|
||||||
|
assertEquals(true, section.contains("- b — second"))
|
||||||
|
assertEquals(true, section.contains("- c — third"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not mention auto-activation - per design contract`() {
|
||||||
|
val section = SystemPromptToolsetSection.render(
|
||||||
|
active = emptyList(),
|
||||||
|
inactive = listOf(stub("a", "x")),
|
||||||
|
)!!
|
||||||
|
// Дизайн-док: auto-activation НЕ в промпте (только в dispatch)
|
||||||
|
assertEquals(false, section.contains("auto", ignoreCase = true))
|
||||||
|
assertEquals(false, section.contains("автоматическ", ignoreCase = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `registry-based render filters by active names`() = runTest {
|
||||||
|
val reg = ToolsetRegistry(listOf(
|
||||||
|
stub("a", "first"),
|
||||||
|
stub("b", "second"),
|
||||||
|
))
|
||||||
|
reg.activate("a")
|
||||||
|
val section = SystemPromptToolsetSection.render(reg, setOf("a"))
|
||||||
|
assertEquals(true, section!!.contains("- a — first"))
|
||||||
|
assertEquals(true, section.contains("- b — second"))
|
||||||
|
assertEquals(true, section.contains("Active:"))
|
||||||
|
assertEquals(true, section.contains("Inactive:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty registry renders null`() = runTest {
|
||||||
|
val reg = ToolsetRegistry.empty()
|
||||||
|
assertNull(SystemPromptToolsetSection.render(reg, setOf()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ToolsetDispatchPolicyTest {
|
||||||
|
|
||||||
|
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||||
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
|
override fun invoke(arguments: String) = response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ts(name: String, toolNames: List<String>): ToolsetContribution = ToolsetContribution(
|
||||||
|
name = name,
|
||||||
|
description = "toolset $name",
|
||||||
|
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Helper: build policy + expose its registry для assert-ов в тестах. */
|
||||||
|
private class Harness(
|
||||||
|
val policy: ToolsetDispatchPolicy,
|
||||||
|
val registry: ToolsetRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun harness(
|
||||||
|
toolsets: List<ToolsetContribution>,
|
||||||
|
baseKnown: Set<String> = setOf("memory_save"),
|
||||||
|
): Harness {
|
||||||
|
val registry = ToolsetRegistry(toolsets)
|
||||||
|
val base: BaseToolDispatcher = { n, a ->
|
||||||
|
if (n in baseKnown) "base:$n:$a" else error("unknown base tool: $n")
|
||||||
|
}
|
||||||
|
return Harness(ToolsetDispatchPolicy(registry, base), registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `active tool is dispatched directly`() = runTest {
|
||||||
|
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||||
|
h.registry.activate("media")
|
||||||
|
val outcome = h.policy.dispatch("resize_image", "{}")
|
||||||
|
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||||
|
assertEquals("media", ran.toolsetName)
|
||||||
|
assertEquals("resize_image", ran.toolName)
|
||||||
|
assertEquals("ok:resize_image", ran.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive tool triggers auto-activation`() = runTest {
|
||||||
|
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||||
|
assertFalse(h.registry.isActive("media"))
|
||||||
|
val outcome = h.policy.dispatch("resize_image", "{}")
|
||||||
|
assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||||
|
// auto-activation: тулсет теперь активен
|
||||||
|
assertTrue(h.registry.isActive("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown tool falls through to base dispatcher`() = runTest {
|
||||||
|
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||||
|
val outcome = h.policy.dispatch("memory_save", """{"key":"value"}""")
|
||||||
|
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||||
|
assertEquals(null, ran.toolsetName)
|
||||||
|
assertEquals("memory_save", ran.toolName)
|
||||||
|
assertEquals("base:memory_save:{\"key\":\"value\"}", ran.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive tool wins over base fallback for shared name`() = runTest {
|
||||||
|
// Тулу "shared" принадлежит тулсет (inactive), и в base диспетчере тоже
|
||||||
|
// есть "shared". Должен победить тулсет (с auto-activation), не base.
|
||||||
|
val h = harness(
|
||||||
|
listOf(ts("ts", listOf("shared"))),
|
||||||
|
baseKnown = setOf("shared"),
|
||||||
|
)
|
||||||
|
val outcome = h.policy.dispatch("shared", "{}")
|
||||||
|
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
|
||||||
|
assertEquals("ts", ran.toolsetName)
|
||||||
|
assertEquals("ok:shared", ran.result)
|
||||||
|
assertTrue(h.registry.isActive("ts"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completely unknown tool bubbles up from base dispatcher`() = runTest {
|
||||||
|
val h = harness(listOf(ts("media", listOf("resize_image"))))
|
||||||
|
// base dispatcher бросает IllegalStateException — это распространяется
|
||||||
|
// через suspend и попадает в вызывающий код. Это OK: вызывающий код
|
||||||
|
// (ChatAgent) ловит исключения тулов и формирует tool_result с ошибкой.
|
||||||
|
val ex = runCatching {
|
||||||
|
kotlinx.coroutines.runBlocking { h.policy.dispatch("totally_unknown_tool", "{}") }
|
||||||
|
}.exceptionOrNull()
|
||||||
|
assertTrue(ex is IllegalStateException, "expected ISE, got $ex")
|
||||||
|
assertTrue(ex.message!!.contains("totally_unknown_tool"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ToolsetRegistryTest {
|
||||||
|
|
||||||
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
|
override fun invoke(arguments: String) = "ok:$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contribution(
|
||||||
|
name: String,
|
||||||
|
description: String = "test toolset",
|
||||||
|
toolNames: List<String> = listOf("tool1"),
|
||||||
|
): ToolsetContribution = ToolsetContribution(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty registry has no active tools`() = runTest {
|
||||||
|
val r = ToolsetRegistry.empty()
|
||||||
|
assertEquals(emptyList(), r.activeNames())
|
||||||
|
assertEquals(emptyList(), r.activeTools())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all returns registered contributions`() {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||||
|
assertEquals(listOf("a", "b"), r.names())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findByName returns matching contribution or null`() {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||||
|
assertNotNull(r.findByName("a"))
|
||||||
|
assertEquals("test toolset", r.findByName("a")?.description)
|
||||||
|
assertNull(r.findByName("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activate changes state and isActive reports true`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||||
|
assertFalse(r.isActive("a"))
|
||||||
|
r.activate("a")
|
||||||
|
assertTrue(r.isActive("a"))
|
||||||
|
assertEquals(listOf("a"), r.activeNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activate is idempotent - second call is no-op`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||||
|
r.activate("a")
|
||||||
|
r.activate("a")
|
||||||
|
assertEquals(1, r.activeNames().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivate removes from active`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
|
||||||
|
r.activate("a")
|
||||||
|
r.activate("b")
|
||||||
|
r.deactivate("a")
|
||||||
|
assertEquals(listOf("b"), r.activeNames())
|
||||||
|
assertFalse(r.isActive("a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deactivate on inactive is no-op`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a")))
|
||||||
|
r.deactivate("a") // never activated
|
||||||
|
assertEquals(emptyList(), r.activeNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactiveNames returns the complement of active`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b"), contribution("c")))
|
||||||
|
r.activate("a")
|
||||||
|
r.activate("c")
|
||||||
|
assertEquals(listOf("b"), r.inactiveNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `activeTools returns the LiteTool instances from active toolsets`() = runTest {
|
||||||
|
val r = ToolsetRegistry(listOf(
|
||||||
|
contribution("ts1", toolNames = listOf("t1", "t2")),
|
||||||
|
contribution("ts2", toolNames = listOf("t3")),
|
||||||
|
))
|
||||||
|
r.activate("ts1")
|
||||||
|
r.activate("ts2")
|
||||||
|
val tools = r.activeTools()
|
||||||
|
assertEquals(3, tools.size)
|
||||||
|
// Проверяем что имена извлекаются из describe()
|
||||||
|
val names = tools.map { it.nameFromDescribe() }.toSet()
|
||||||
|
assertEquals(setOf("t1", "t2", "t3"), names)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findOwnerByToolName locates the owning toolset`() {
|
||||||
|
val r = ToolsetRegistry(listOf(
|
||||||
|
contribution("ts1", toolNames = listOf("t1", "t2")),
|
||||||
|
contribution("ts2", toolNames = listOf("t3")),
|
||||||
|
))
|
||||||
|
val owner = r.findOwnerByToolName("t2")
|
||||||
|
assertNotNull(owner)
|
||||||
|
assertEquals("ts1", owner.first.name)
|
||||||
|
assertEquals("t2", owner.second.toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findOwnerByToolName returns null for unknown tool`() {
|
||||||
|
val r = ToolsetRegistry(listOf(contribution("ts1", toolNames = listOf("t1"))))
|
||||||
|
assertNull(r.findOwnerByToolName("nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `close is idempotent and does nothing`() {
|
||||||
|
val r = ToolsetRegistry.empty()
|
||||||
|
r.close()
|
||||||
|
r.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# `:agentik-cli` — one-shot CLI клиент к `/agentik`
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
**One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
|
||||||
|
`:standalone` через `:client` по HTTP+SSE. Один вызов — одна команда:
|
||||||
|
стрим ответа `send` идёт в stdout построчно, никакого embedded-REPL.
|
||||||
|
|
||||||
|
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
|
||||||
|
не поднимая отдельную TUI-сессии.
|
||||||
|
|
||||||
|
## Платформы
|
||||||
|
|
||||||
|
| Платформа | Артефакт | Размер | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `jvm` (JRE 21) | `*-all.jar` | ~7 МБ | ✓ собирается и работает |
|
||||||
|
| `linuxX64` | `.kexe` | ~5 МБ | ✓ собирается и работает на этом хосте |
|
||||||
|
| `macosX64` | `.kexe` | — | собирается на macOS-раннере |
|
||||||
|
| `macosArm64` | `.kexe` | — | собирается на macOS-arm64-раннере |
|
||||||
|
| `mingwX64` | `.exe` | ~6 МБ | ✓ собирается (cross-compile с Linux) |
|
||||||
|
| `linuxArm64` | — | — | **нет** — kotlinx-cli 0.3.6 не публикует klib для linuxArm64 |
|
||||||
|
| `iOS` | — | — | нет смысла на iOS |
|
||||||
|
|
||||||
|
## Подкоманды
|
||||||
|
|
||||||
|
```
|
||||||
|
agentik-cli <command> [args...]
|
||||||
|
|
||||||
|
Команды верхнего уровня:
|
||||||
|
conv <subcommand> операции над диалогами (см. ниже)
|
||||||
|
msgs <id> [--limit N] показать сообщения
|
||||||
|
send <id> <text...> отправить ход, стримит response-события в stdout
|
||||||
|
interrupt <id> прервать текущий ход
|
||||||
|
info показать конфиг (server URL + agent id)
|
||||||
|
|
||||||
|
Подкоманды `conv`:
|
||||||
|
conv ls список диалогов
|
||||||
|
conv new [--temp] создать диалог, печатает id
|
||||||
|
conv show <id> метаданные диалога
|
||||||
|
conv delete <id> удалить диалог
|
||||||
|
conv rename <id> <title> переименовать
|
||||||
|
```
|
||||||
|
|
||||||
|
`--server URL` и `--id ID` (env: `AGENTIK_SERVER`, `AGENTIK_AGENT_ID`)
|
||||||
|
задаются **после** имени subcommand'а — kotlinx.cli не шарит опции
|
||||||
|
родителя в subcommand. Примеры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agentik-cli conv ls --server http://192.168.76.166:8080/agentik
|
||||||
|
agentik-cli conv new --server http://localhost:8080/agentik
|
||||||
|
agentik-cli send --server http://localhost:8080/agentik conv-abc "привет"
|
||||||
|
agentik-cli info # через AGENTIK_SERVER env-переменную
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### JVM (fatjar)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-cli:shadowJar
|
||||||
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
|
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native linuxX64
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
|
||||||
|
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native macOS / Windows
|
||||||
|
|
||||||
|
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
|
||||||
|
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
|
||||||
|
кросс-компиляцию.
|
||||||
|
|
||||||
|
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
|
||||||
|
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
|
||||||
|
раннере Gitea Actions.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Список диалогов (таблица)
|
||||||
|
agentik-cli conv ls --server http://localhost:8080/agentik
|
||||||
|
|
||||||
|
# Создать диалог
|
||||||
|
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
|
||||||
|
echo "new conv: $ID"
|
||||||
|
|
||||||
|
# Переименовать
|
||||||
|
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
|
||||||
|
|
||||||
|
# Отправить ход и стримить ответ
|
||||||
|
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
|
||||||
|
|
||||||
|
# Показать последние N сообщений
|
||||||
|
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
|
||||||
|
|
||||||
|
# Прервать активный ход
|
||||||
|
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
|
||||||
|
|
||||||
|
# Удалить
|
||||||
|
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
|
||||||
|
|
||||||
|
# Через env-переменную
|
||||||
|
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат вывода `send`
|
||||||
|
|
||||||
|
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
|
||||||
|
пригодно для парсинга через `awk`/`jq`-обёртки:
|
||||||
|
|
||||||
|
```
|
||||||
|
event StartReasoning
|
||||||
|
event StartResponse TEXT
|
||||||
|
event AppendText \n\n
|
||||||
|
event AppendText Привет!
|
||||||
|
event End
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминальные события (`End`, `Interrupted`, `Error`) тоже
|
||||||
|
печатаются; CLI выходит сразу после `End`.
|
||||||
|
|
||||||
|
## Почему kotlinx.cli (а не clikt)
|
||||||
|
|
||||||
|
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
|
||||||
|
arg-parser, который стабильно линкуется под `linux_x64` +
|
||||||
|
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
|
||||||
|
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
|
||||||
|
ломается на native linker: `duplicate symbol selfAndAncestors`
|
||||||
|
между `clikt` и `clikt-mordant` commonMain (issue
|
||||||
|
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
|
||||||
|
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
|
||||||
|
сборку на порядки и не решает проблему до конца. Поэтому clikt
|
||||||
|
отвергнут.
|
||||||
|
|
||||||
|
## Платформенные детали
|
||||||
|
|
||||||
|
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
|
||||||
|
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
|
||||||
|
convention `MainKt.main` тут не работает — K/N линкер ищет
|
||||||
|
функцию по `package.main`.
|
||||||
|
- **`platformEnv(key)`** для чтения env-переменных:
|
||||||
|
- JVM: `System.getenv(key)` через `jvmMain` actual.
|
||||||
|
- Native: `getenv(key)` из `platform.posix` через
|
||||||
|
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
|
||||||
|
требует `@OptIn(ExperimentalForeignApi::class)`).
|
||||||
|
- **Stdout / exit code** — работают на K/N через корутины.
|
||||||
|
|
||||||
|
## Готчасы kotlinx.cli
|
||||||
|
|
||||||
|
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
|
||||||
|
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
|
||||||
|
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
|
||||||
|
сделан no-op (`override fun execute() = Unit`), иначе вывод
|
||||||
|
дочерней команды дублируется выводом родителя. Дочерние команды
|
||||||
|
смотрятся через `agentik-cli conv --help`.
|
||||||
|
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
|
||||||
|
парсится как `conv [--server ...]` + позиционный аргумент `new`
|
||||||
|
на уровне родителя — и дочерняя команда не запускается.
|
||||||
|
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
|
||||||
|
покрывается руками против живого сервера.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
|
||||||
|
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
|
||||||
|
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
|
||||||
|
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
|
||||||
|
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
|
||||||
|
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
|
||||||
|
// поэтому clikt отвергнут.
|
||||||
|
//
|
||||||
|
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
|
||||||
|
// его потребитель) тоже без iOS.
|
||||||
|
jvm()
|
||||||
|
listOf(
|
||||||
|
linuxX64(),
|
||||||
|
macosX64(),
|
||||||
|
macosArm64(),
|
||||||
|
mingwX64(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
implementation(project(":client"))
|
||||||
|
|
||||||
|
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
|
||||||
|
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
|
||||||
|
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
|
||||||
|
// таких проблем нет.
|
||||||
|
implementation(libs.kotlinx.cli)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||||
|
// весь код, включая platformEnv, лежит в commonMain.
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
|
||||||
|
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
|
||||||
|
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
|
||||||
|
// mainClass.set(...) выше.
|
||||||
|
listOf(
|
||||||
|
linuxX64(),
|
||||||
|
macosX64(),
|
||||||
|
macosArm64(),
|
||||||
|
mingwX64(),
|
||||||
|
).forEach {
|
||||||
|
it.binaries.executable {
|
||||||
|
entryPoint = "pw.binom.agentik.cli.main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatjar — аналог :standalone.
|
||||||
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
|
archiveBaseName.set("agentik-cli")
|
||||||
|
archiveClassifier.set("all")
|
||||||
|
description = "Self-contained fatjar with all runtime dependencies bundled."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
from(tasks.named("jvmJar"))
|
||||||
|
from(project.configurations.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
mergeServiceFiles()
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-cli"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgParser
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.ExperimentalCli
|
||||||
|
import kotlinx.cli.Subcommand
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.commands.ConvCommand
|
||||||
|
import pw.binom.agentik.cli.commands.InfoSubcommand
|
||||||
|
import pw.binom.agentik.cli.commands.InterruptSubcommand
|
||||||
|
import pw.binom.agentik.cli.commands.MsgsSubcommand
|
||||||
|
import pw.binom.agentik.cli.commands.SendSubcommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
|
||||||
|
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
|
||||||
|
*
|
||||||
|
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
|
||||||
|
* `--server`/`--id` (иначе subcommand не видит значения родителя).
|
||||||
|
*/
|
||||||
|
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-cli <command> [args...]
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* conv ls|new|show|delete|rename операции над диалогами
|
||||||
|
* msgs <id> [--limit N] показать сообщения
|
||||||
|
* send <id> <text...> отправить ход, стримит response-события в stdout
|
||||||
|
* interrupt <id> прервать текущий ход
|
||||||
|
* info показать конфиг
|
||||||
|
*
|
||||||
|
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
|
||||||
|
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
|
||||||
|
* шарит опции родителя в subcommand.
|
||||||
|
*
|
||||||
|
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
|
||||||
|
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
|
||||||
|
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
|
||||||
|
* регистрируются у него.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCli::class)
|
||||||
|
fun runAgentikCli(args: Array<String>) {
|
||||||
|
val parser = ArgParser(
|
||||||
|
programName = "agentik-cli",
|
||||||
|
// Все аргументы после имени subcommand должны передаваться
|
||||||
|
// В subcommand-парсер, а не парситься на уровне родителя.
|
||||||
|
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
|
||||||
|
// + аргумент "new" → execute родителя, без вложенной команды.
|
||||||
|
strictSubcommandOptionsOrder = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val conv = ConvCommand()
|
||||||
|
parser.subcommands(
|
||||||
|
conv,
|
||||||
|
MsgsSubcommand(),
|
||||||
|
SendSubcommand(),
|
||||||
|
InterruptSubcommand(),
|
||||||
|
InfoSubcommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.parse(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
|
||||||
|
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
|
||||||
|
* свойства родителя в subcommand).
|
||||||
|
*/
|
||||||
|
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
|
||||||
|
val serverUrl: String by option(
|
||||||
|
ArgType.String, fullName = "server", shortName = "s",
|
||||||
|
description = "Base URL агента (env AGENTIK_SERVER)",
|
||||||
|
).default(defaultServerUrl())
|
||||||
|
val agentId: String by option(
|
||||||
|
ArgType.String, fullName = "id", shortName = "i",
|
||||||
|
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
|
||||||
|
).default(defaultAgentId())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
runAgentikCli(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
internal expect fun platformEnv(key: String): String?
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ExperimentalCli
|
||||||
|
import kotlinx.cli.Subcommand
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Родительская группа `conv`: операции над диалогами.
|
||||||
|
*
|
||||||
|
* Сама команда `agentik-cli conv` (без подкоманды) — no-op:
|
||||||
|
* в kotlinx.cli parent.execute() вызывается ПОСЛЕ leaf.execute(),
|
||||||
|
* поэтому любая работа в execute() дублирует вывод дочерней команды.
|
||||||
|
* Для просмотра дочерних команд есть `agentik-cli conv --help`.
|
||||||
|
*
|
||||||
|
* Дочерние команды регистрируются через [subcommands] в конструкторе.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCli::class)
|
||||||
|
class ConvCommand : Subcommand("conv", "Операции над диалогами") {
|
||||||
|
init {
|
||||||
|
subcommands(
|
||||||
|
ConvLsSubcommand(),
|
||||||
|
ConvNewSubcommand(),
|
||||||
|
ConvShowSubcommand(),
|
||||||
|
ConvDeleteSubcommand(),
|
||||||
|
ConvRenameSubcommand(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ConvSubcommand(name: String, description: String) : AgentikSubcommand(name, description)
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val ok = agent.deleteConversation(id)
|
||||||
|
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов агента") {
|
||||||
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||||
|
if (convs.isEmpty()) {
|
||||||
|
println("(no conversations)")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
println("ID UPDATED-AT TITLE FLAGS")
|
||||||
|
convs.forEach { c ->
|
||||||
|
val flags = buildString {
|
||||||
|
if (c.isTemporal) append('T')
|
||||||
|
if (c.isSupportImageInput) append('I')
|
||||||
|
if (c.isSupportImageOutput) append('O')
|
||||||
|
if (isEmpty()) append('-')
|
||||||
|
}
|
||||||
|
val title = c.title ?: "(untitled)"
|
||||||
|
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
|
||||||
|
}
|
||||||
|
println("--- ${convs.size} conversation(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||||
|
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.createConversation(temp = temp)
|
||||||
|
println(conv.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val title by argument(ArgType.String, description = "Новое название")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conv.rename(title)
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
println("renamed: $id -> $title")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
println("id: ${conv.id}")
|
||||||
|
println("title: ${conv.title ?: "(untitled)"}")
|
||||||
|
println("updatedAt: ${conv.updatedAt}")
|
||||||
|
println("isTemporal: ${conv.isTemporal}")
|
||||||
|
println("isSupportImageInput: ${conv.isSupportImageInput}")
|
||||||
|
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
|
||||||
|
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
|
||||||
|
override fun execute() {
|
||||||
|
println("server: $serverUrl")
|
||||||
|
println("id: $agentId")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conv.interrupt()
|
||||||
|
println("interrupted: $id")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
|
||||||
|
.sortedBy { it.date }
|
||||||
|
msgs.forEach { m -> println(formatMessage(m)) }
|
||||||
|
println("--- ${msgs.size} message(s)")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(m: Message): String =
|
||||||
|
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
|
||||||
|
|
||||||
|
private fun Message.role(): String = when (this) {
|
||||||
|
is Message.UserMessage -> "[user]"
|
||||||
|
is Message.AssistantMessage -> "[assistant]"
|
||||||
|
is Message.ToolCall -> "[tool_call]"
|
||||||
|
is Message.ToolResult -> "[tool_result]"
|
||||||
|
is Message.Error -> "[error]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Message.bodyOneLine(): String = when (this) {
|
||||||
|
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||||
|
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||||
|
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
|
||||||
|
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
|
||||||
|
is Message.Error -> "code=${code ?: "?"} message=$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Content.toOneLine(): String = when (this) {
|
||||||
|
is Content.Text -> body.replace('\n', ' ').take(200)
|
||||||
|
is Content.Image -> "<image ${data.size}B $mime>"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.vararg
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Подписываемся на поток событий ДО send: события, отправленные
|
||||||
|
// до подписки, не реплеятся (shared-flow без replay).
|
||||||
|
val eventsJob = launch {
|
||||||
|
conv.events(Instant.DISTANT_PAST)
|
||||||
|
// onEach печатает и терминальный event, takeWhile лишь
|
||||||
|
// завершает сбор после него.
|
||||||
|
.onEach { ev -> emit(ev) }
|
||||||
|
.takeWhile { ev -> !isTerminal(ev) }
|
||||||
|
.collect { }
|
||||||
|
}
|
||||||
|
// Даём SSE-подписке установиться, затем шлём ход.
|
||||||
|
delay(200)
|
||||||
|
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
||||||
|
eventsJob.join()
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTerminal(ev: Event): Boolean =
|
||||||
|
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
|
||||||
|
|
||||||
|
private fun emit(ev: Event) {
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartReasoning -> println("event StartReasoning")
|
||||||
|
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
|
||||||
|
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||||
|
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||||
|
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||||
|
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
||||||
|
is Event.End -> println("event End")
|
||||||
|
is Event.Interrupted -> println("event Interrupted")
|
||||||
|
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.toKString
|
||||||
|
import platform.posix.getenv
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
internal actual fun platformEnv(key: String): String? = getenv(key)?.toKString()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Compose-style TUI-клиент в терминале на базе
|
||||||
|
[Mosaic](https://github.com/JakeWharton/mosaic) (Jetpack Compose
|
||||||
|
runtime, рендерится в ANSI-коды). Без `:`-команд (без vim-style
|
||||||
|
prompt): клавиатурная навигация Tab/Enter/Esc/Ctrl-D/F1/стрелки +
|
||||||
|
жирный focus indicator.
|
||||||
|
|
||||||
|
- **Layout**: header (id/conv/focus) + history + input + footer.
|
||||||
|
- **Focus**: Tab/Shift-Tab цикл по фокусам (input → history → sidebar).
|
||||||
|
- **Input**: стандартное текстовое поле с курсором `|` посередине.
|
||||||
|
- **Stream**: подписка на SSE в фон-корутинах, `StateFlow` + `collectAsState()`
|
||||||
|
для UI-реактивности (см. Snake sample).
|
||||||
|
|
||||||
|
Решает: полноценный TUI-клиент для тех, кто предпочитает мышкой
|
||||||
|
кликать в терминале больше, чем печатать. В отличие от `:agentik-cli`,
|
||||||
|
показывает историю диалога и текущий стрим в одном окне.
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- JVM 21+.
|
||||||
|
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
||||||
|
- Реальный TTY (через `ssh -tt`, `tmux`, либо нативный terminal).
|
||||||
|
|
||||||
|
### Запуск из готового fatjar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
|
-jar agentik-tui-0.1.0-all.jar \
|
||||||
|
--server http://192.168.76.166:8080/agentik
|
||||||
|
```
|
||||||
|
|
||||||
|
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
|
||||||
|
native syscalls для терминала.
|
||||||
|
|
||||||
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры CLI
|
||||||
|
|
||||||
|
| Флаг | ENV | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
||||||
|
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
||||||
|
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
||||||
|
| `--help` | — | Показывает help и выходит |
|
||||||
|
|
||||||
|
## Keybindings
|
||||||
|
|
||||||
|
| Клавиша | Когда | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `Tab` / `Shift-Tab` | глобально | Цикл фокусов: input → history → sidebar → ... |
|
||||||
|
| `F1` | глобально | Toggle help overlay |
|
||||||
|
| `Esc` | в input | Очистить input |
|
||||||
|
| `Enter` | в input | Submit message |
|
||||||
|
| `Backspace` / `Del` | в input | Удалить символ |
|
||||||
|
| `←` `→` `Home` `End` | в input | Курсор |
|
||||||
|
| `↑` `↓` | в history | Scrollback |
|
||||||
|
| `Ctrl-D` / `Ctrl-C` | — | Exit (TODO — пока работает только вне стрима) |
|
||||||
|
|
||||||
|
## Переменные среды (сервера)
|
||||||
|
|
||||||
|
См. [`../standalone/README.md`](../standalone/README.md). TUI
|
||||||
|
получает URL сервера через `--server`, остальное настройка
|
||||||
|
агента, а не клиента.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
|
||||||
|
же, что для `:agentik-cli`.
|
||||||
|
2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
|
||||||
|
built-in mouse-runtime). Планируется в v3 через termios
|
||||||
|
SGR-mouse.
|
||||||
|
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
|
||||||
|
собраны, но без `:client` (он JVM-only). Для нативной работы
|
||||||
|
нужен альтернативный HTTP-клиент.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agentik-tui:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты composable'ов и event-рендеринга. Включает smoke-test для
|
||||||
|
key-event → AppState mutation → ре-рендер.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-tui`.
|
||||||
|
|
||||||
|
## Архитектурная заметка
|
||||||
|
|
||||||
|
UI-стейт держится в `StateFlow`, а **не** в Compose `mutableStateOf`.
|
||||||
|
Причина: Mosaic 0.18 не триггерит recomposition от `mutableStateOf`
|
||||||
|
-writes внутри `onPreviewKeyEvent`-handler'ов (см. Snake sample в
|
||||||
|
репо Mosaic — они тоже используют `StateFlow` + `collectAsState()`).
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
// Suppress Beta-предупреждения от expect/actual объектов.
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
|
||||||
|
// iOS пропускаем — на iOS не бывает TUI-сессий.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
// Native executables. По умолчанию Kotlin/Native для каждого target'а
|
||||||
|
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
|
||||||
|
// явно попросить binaries.executable(). entryPoint нужно задать явно:
|
||||||
|
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
|
||||||
|
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
|
||||||
|
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
|
||||||
|
//
|
||||||
|
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
|
||||||
|
// явно (а не через targets.withType), потому что targets DSL в KMP не
|
||||||
|
// поддерживает реифицированный withType<KotlinNativeTarget>().
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
|
||||||
|
it.binaries.executable {
|
||||||
|
entryPoint = "pw.binom.agentik.tui.main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||||
|
implementation(libs.mosaic.runtime)
|
||||||
|
implementation(libs.mosaic.tty.terminal)
|
||||||
|
|
||||||
|
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
|
||||||
|
// там работает stub actual через expect/actual.
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(project(":client"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.tui.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fatjar (uberjar) ---
|
||||||
|
//
|
||||||
|
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
|
||||||
|
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
|
||||||
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
|
archiveBaseName.set("agentik-tui")
|
||||||
|
archiveClassifier.set("all")
|
||||||
|
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
from(tasks.named("jvmJar"))
|
||||||
|
val cc = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
}
|
||||||
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
mergeServiceFiles()
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-tui"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Row
|
||||||
|
import pw.binom.agentik.tui.ui.Footer
|
||||||
|
import pw.binom.agentik.tui.ui.Header
|
||||||
|
import pw.binom.agentik.tui.ui.HelpOverlay
|
||||||
|
import pw.binom.agentik.tui.ui.HistoryPanel
|
||||||
|
import pw.binom.agentik.tui.ui.InputLine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
|
||||||
|
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
|
||||||
|
*
|
||||||
|
* Layout (минимальный):
|
||||||
|
* ```
|
||||||
|
* ┌─────────────────────────────────────────────────────────┐
|
||||||
|
* │ HEADER: agentik · id · conv-id · focus=… │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ HISTORY (весь актуальный диалог) │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ INPUT LINE: > text| │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
|
* └─────────────────────────────────────────────────────────┘
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||||
|
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun App(state: AppState) {
|
||||||
|
val focusIndex by state.focusIndex.collectAsState()
|
||||||
|
val showHelp by state.showHelp.collectAsState()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
|
||||||
|
when (ev.key) {
|
||||||
|
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
|
||||||
|
"F1" -> { state.toggleHelp(); true }
|
||||||
|
"Escape", "Esc" -> {
|
||||||
|
if (showHelp) state.setShowHelp(false)
|
||||||
|
else if (focusIndex == 0) state.inputClear()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Header(state, focusIndex)
|
||||||
|
HistoryPanel(state)
|
||||||
|
InputLine(state)
|
||||||
|
Footer(showHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showHelp) HelpOverlay()
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние TUI. По дизайну — singleton, переживает все экраны.
|
||||||
|
*
|
||||||
|
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
|
||||||
|
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
|
||||||
|
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
|
||||||
|
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
|
||||||
|
* (см. samples/snake в репо Mosaic).
|
||||||
|
*/
|
||||||
|
internal class AppState(val config: TuiConfig) {
|
||||||
|
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
|
||||||
|
private var backend: TuiBackend? = null
|
||||||
|
fun attachBackend(b: TuiBackend) { backend = b }
|
||||||
|
|
||||||
|
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||||
|
private val _focusIndex = MutableStateFlow(0)
|
||||||
|
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||||
|
|
||||||
|
/** Видимость help-оверлея. */
|
||||||
|
private val _showHelp = MutableStateFlow(false)
|
||||||
|
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
|
||||||
|
|
||||||
|
/** Сообщения диалога. */
|
||||||
|
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
/** Заголовок текущего диалога. */
|
||||||
|
private val _currentTitle = MutableStateFlow<String?>(null)
|
||||||
|
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
|
||||||
|
|
||||||
|
/** ID текущего диалога. */
|
||||||
|
private val _currentConversationId = MutableStateFlow<String?>(null)
|
||||||
|
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
|
||||||
|
|
||||||
|
/** Список диалогов (sidebar). */
|
||||||
|
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
|
||||||
|
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в списке диалогов. */
|
||||||
|
private val _conversationsCursor = MutableStateFlow(0)
|
||||||
|
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Поле ввода. */
|
||||||
|
private val _input = MutableStateFlow("")
|
||||||
|
val input: StateFlow<String> = _input.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в input (offset в chars). */
|
||||||
|
private val _cursor = MutableStateFlow(0)
|
||||||
|
val cursor: StateFlow<Int> = _cursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Идёт ли стрим. */
|
||||||
|
private val _streaming = MutableStateFlow(false)
|
||||||
|
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
|
||||||
|
|
||||||
|
/** Scrollback index: 0 = прижат к низу. */
|
||||||
|
private val _historyScroll = MutableStateFlow(0)
|
||||||
|
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
|
||||||
|
|
||||||
|
// ---------- мутации ----------
|
||||||
|
|
||||||
|
fun cycleFocus(direction: Int = +1) {
|
||||||
|
_focusIndex.value = (_focusIndex.value + direction).mod(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleHelp() { _showHelp.value = !_showHelp.value }
|
||||||
|
fun setShowHelp(v: Boolean) { _showHelp.value = v }
|
||||||
|
|
||||||
|
fun inputInsert(s: String) {
|
||||||
|
val pos = _cursor.value.coerceIn(0, _input.value.length)
|
||||||
|
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos + s.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputBackspace() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos <= 0) return
|
||||||
|
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputDelete() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos >= _input.value.length) return
|
||||||
|
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputClear() { _input.value = ""; _cursor.value = 0 }
|
||||||
|
|
||||||
|
fun inputMoveCursor(delta: Int) {
|
||||||
|
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
|
||||||
|
}
|
||||||
|
fun inputCursorHome() { _cursor.value = 0 }
|
||||||
|
fun inputCursorEnd() { _cursor.value = _input.value.length }
|
||||||
|
|
||||||
|
fun submitInput(): String? {
|
||||||
|
val text = _input.value.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||||
|
inputClear()
|
||||||
|
_streaming.value = true
|
||||||
|
backend?.onUserMessage(text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStreaming(v: Boolean) { _streaming.value = v }
|
||||||
|
|
||||||
|
fun setConversation(id: String, title: String?) {
|
||||||
|
_currentConversationId.value = id
|
||||||
|
_currentTitle.value = title
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_historyScroll.value = 0
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postToolCall(toolName: String, title: String?, args: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postToolResult(toolName: String, result: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendAssistant(chunk: String) {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull()
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = last.copy(text = last.text + chunk)
|
||||||
|
} else {
|
||||||
|
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
|
||||||
|
}
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishAssistant() {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull() ?: return
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newConversation(id: String, title: String?) {
|
||||||
|
_currentConversationId.value = id
|
||||||
|
_currentTitle.value = title
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_historyScroll.value = 0
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postSystem(text: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимок диалога для sidebar. */
|
||||||
|
internal data class ConvSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Рендер-единица. */
|
||||||
|
internal sealed interface TuiMessage {
|
||||||
|
val ts: Instant
|
||||||
|
|
||||||
|
data class System(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class User(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа TUI-клиента agentik.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Перед запуском UI — обязательный health-check: `GET {server}/health`.
|
||||||
|
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
|
||||||
|
* Если OK — создаём [Agent] через платформенную actual и запускаем
|
||||||
|
* [TuiApp].
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args) ?: run {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
checkServer(cfg.server)
|
||||||
|
val agent = platformCreateAgent(cfg.server, cfg.id)
|
||||||
|
TuiApp(cfg, agent).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Делает синхронный GET `{baseUrl}/health`. Внутри [route(path)] на сервере
|
||||||
|
* `/health` зарегистрирован под тем же path-prefix'ом, что и сам API
|
||||||
|
* (например, baseUrl = `http://localhost:8080/agentik` → health = …/agentik/health).
|
||||||
|
*
|
||||||
|
* При любой ошибке (connect refused, timeout, не-200 ответ, не `"ok"`) —
|
||||||
|
* бросает [IllegalStateException] с понятным сообщением. [runBlocking]-обёртка
|
||||||
|
* в [main] разворачивает её в stack-trace и `exit 1`.
|
||||||
|
*/
|
||||||
|
private suspend fun checkServer(baseUrl: String) {
|
||||||
|
val healthUrl = "${baseUrl.trimEnd('/')}/health"
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 5_000
|
||||||
|
connectTimeoutMillis = 3_000
|
||||||
|
}
|
||||||
|
expectSuccess = false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val response = client.get(healthUrl)
|
||||||
|
if (response.status.value !in 200..299) {
|
||||||
|
throw IllegalStateException("сервер ответил HTTP ${response.status.value} на GET $healthUrl")
|
||||||
|
}
|
||||||
|
val body = response.bodyAsText().trim()
|
||||||
|
if (body != "ok") {
|
||||||
|
throw IllegalStateException("сервер ответил неожиданным телом на GET $healthUrl: '$body'")
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// На JVM сюда упадут java.net.ConnectException, UnknownHostException,
|
||||||
|
// io.ktor.client.network.sockets.ConnectTimeoutException и т.п.
|
||||||
|
// На нативе native stub падает раньше в platformCreateAgent, так что
|
||||||
|
// сюда мы попадём только под JVM-actual.
|
||||||
|
throw IllegalStateException(
|
||||||
|
"ошибка health-check $healthUrl: ${e::class.simpleName} — ${e.message ?: "(нет сообщения)"}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
|
||||||
|
* Доступна из других файлов commonMain как `internal`.
|
||||||
|
*/
|
||||||
|
internal data class TuiConfig(
|
||||||
|
val server: String,
|
||||||
|
val id: String,
|
||||||
|
val historyEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCliArgs(args: Array<String>): TuiConfig? {
|
||||||
|
var server: String? = null
|
||||||
|
var id: String? = null
|
||||||
|
var historyEnabled = true
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
when (val a = args[i]) {
|
||||||
|
"--help", "-h", "help" -> return null
|
||||||
|
"--server", "-s" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует URL" }
|
||||||
|
server = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--id" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует значение" }
|
||||||
|
id = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--no-history" -> { historyEnabled = false; i++ }
|
||||||
|
"--" -> i++
|
||||||
|
else -> error("неизвестный аргумент: $a (введите --help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val envServer = platformEnv("AGENTIK_SERVER")
|
||||||
|
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
|
||||||
|
val resolvedId = id ?: "cli-tui:${envUser}"
|
||||||
|
|
||||||
|
return TuiConfig(
|
||||||
|
server = resolvedServer,
|
||||||
|
id = resolvedId,
|
||||||
|
historyEnabled = historyEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
|
||||||
|
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
|
||||||
|
*/
|
||||||
|
internal expect fun platformEnv(key: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
|
||||||
|
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
|
||||||
|
*/
|
||||||
|
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
|
||||||
|
|
||||||
|
private fun printUsage() {
|
||||||
|
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
|
||||||
|
println("""
|
||||||
|
agentik-tui — Compose-Mosaic UI поверх протокола agentik
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
agentik-tui [--server URL] [--id ID] [--no-history]
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
--server, -s URL базовый URL (default: $defaultServer)
|
||||||
|
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
|
||||||
|
--no-history не сохранять состояние
|
||||||
|
--help, -h эта справка
|
||||||
|
|
||||||
|
Переменные среды:
|
||||||
|
AGENTIK_SERVER базовый URL (эквивалент --server)
|
||||||
|
USER / USERNAME используется в id клиента по умолчанию
|
||||||
|
|
||||||
|
В UI:
|
||||||
|
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||||
|
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||||
|
← / → двинуть курсор в инпуте
|
||||||
|
Enter отправить сообщение (создаст новый диалог, если их нет)
|
||||||
|
Ctrl-C / Ctrl-D выйти
|
||||||
|
F1 показать подсказки по горячим клавишам
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.jakewharton.mosaic.runMosaicBlocking
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
|
||||||
|
* его coroutine-scope и ждёт завершения приложения.
|
||||||
|
*
|
||||||
|
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
|
||||||
|
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
|
||||||
|
*/
|
||||||
|
internal class TuiApp(
|
||||||
|
private val config: TuiConfig,
|
||||||
|
private val agent: Agent,
|
||||||
|
) {
|
||||||
|
fun run() {
|
||||||
|
runMosaicBlocking {
|
||||||
|
val state = remember { AppState(config) }
|
||||||
|
val backend = remember { TuiBackend(state = state, agent = agent) }
|
||||||
|
LaunchedEffect(backend) {
|
||||||
|
backend.start(this)
|
||||||
|
}
|
||||||
|
state.attachBackend(backend)
|
||||||
|
App(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-логика TUI: мост между [Agent] и [AppState].
|
||||||
|
*
|
||||||
|
* Жизненный цикл:
|
||||||
|
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
|
||||||
|
* пост-сообщение "connected to …".
|
||||||
|
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
|
||||||
|
* 3. При [onUserMessage] — если текущего диалога нет, создаём
|
||||||
|
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
|
||||||
|
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
|
||||||
|
*
|
||||||
|
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
|
||||||
|
*/
|
||||||
|
internal class TuiBackend(
|
||||||
|
private val state: AppState,
|
||||||
|
private val agent: Agent,
|
||||||
|
) {
|
||||||
|
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
|
||||||
|
private var current: Conversation? = null
|
||||||
|
|
||||||
|
/** Активная джоба подписки на [Conversation.events]. */
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
|
||||||
|
/** Последний виденный момент событий — для переподписки при reconnect. */
|
||||||
|
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
|
||||||
|
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
|
||||||
|
*/
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
this.scope = scope
|
||||||
|
state.postSystem("подключено к ${state.config.server}")
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
// штатная отмена при закрытии UI
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка пользовательского сообщения, отправленного из input.
|
||||||
|
*
|
||||||
|
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
|
||||||
|
* события конкретного диалога стартует в [ensureConversation].
|
||||||
|
*/
|
||||||
|
fun onUserMessage(text: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val conv = ensureConversation()
|
||||||
|
conv.send(listOf(Content.Text(text)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
|
||||||
|
state.setStreaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
|
||||||
|
*/
|
||||||
|
private suspend fun ensureConversation(): Conversation {
|
||||||
|
current?.let { return it }
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
state.setConversation(id = conv.id, title = conv.title)
|
||||||
|
subscribeEvents(conv, Instant.DISTANT_PAST)
|
||||||
|
current = conv
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
||||||
|
*/
|
||||||
|
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
conv.events(from).collect { ev -> dispatch(ev) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг [Event] → [AppState] (что показать в TUI).
|
||||||
|
*
|
||||||
|
* - AppendText → дописывает в последний ассистентский чанк
|
||||||
|
* - StartReasoning / StartResponse → новый streaming-чанк
|
||||||
|
* - End → закрывает streaming
|
||||||
|
* - Interrupted → закрывает streaming + системное сообщение
|
||||||
|
* - ToolCall / ToolResult → сообщения в историю
|
||||||
|
* - Error → системное сообщение
|
||||||
|
*/
|
||||||
|
private fun dispatch(ev: Event) {
|
||||||
|
lastSeenAt = ev.date
|
||||||
|
when (ev) {
|
||||||
|
is Event.AppendText -> state.appendAssistant(ev.body)
|
||||||
|
is Event.StartReasoning -> {
|
||||||
|
state.postSystem("… думаю")
|
||||||
|
}
|
||||||
|
is Event.StartResponse -> state.setStreaming(true)
|
||||||
|
is Event.End -> state.finishAssistant()
|
||||||
|
is Event.Interrupted -> {
|
||||||
|
state.finishAssistant()
|
||||||
|
state.postSystem("прервано")
|
||||||
|
}
|
||||||
|
is Event.AppendImage -> {
|
||||||
|
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
||||||
|
}
|
||||||
|
is Event.ToolCall -> {
|
||||||
|
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
||||||
|
}
|
||||||
|
is Event.ToolResult -> {
|
||||||
|
state.postToolResult(toolName = "", result = ev.result ?: "")
|
||||||
|
}
|
||||||
|
is Event.Error -> {
|
||||||
|
state.setStreaming(false)
|
||||||
|
state.postSystem("ошибка: ${ev.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя подсказка с текущим набором горячих клавиш.
|
||||||
|
*
|
||||||
|
* При открытом help-оверлее показывает заглушку с указателем «наверху».
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Footer(showHelp: Boolean) {
|
||||||
|
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||||
|
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||||
|
Text(value = hint, textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
|
||||||
|
*
|
||||||
|
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Header(state: AppState, focusIndex: Int) {
|
||||||
|
val title by state.currentTitle.collectAsState()
|
||||||
|
val convId by state.currentConversationId.collectAsState()
|
||||||
|
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||||
|
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||||
|
val titleStr = title ?: "(нет диалога)"
|
||||||
|
Text(
|
||||||
|
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||||
|
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полноэкранный оверлей со списком горячих клавиш.
|
||||||
|
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HelpOverlay() {
|
||||||
|
Column(modifier = Modifier) {
|
||||||
|
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||||
|
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||||
|
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||||
|
Text(value = " ← / → курсор в input")
|
||||||
|
Text(value = " Enter отправить сообщение")
|
||||||
|
Text(value = " Backspace / Del удалить символ")
|
||||||
|
Text(value = " Esc очистить input")
|
||||||
|
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||||
|
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
import pw.binom.agentik.tui.TuiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прокручиваемый (через клавиатуру) лог диалога.
|
||||||
|
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
|
||||||
|
* При пустом списке показывается подсказка.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryPanel(state: AppState) {
|
||||||
|
val messages by state.messages.collectAsState()
|
||||||
|
val rendered = if (messages.isEmpty()) {
|
||||||
|
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||||
|
} else {
|
||||||
|
messages.joinToString("") { renderMessage(it) }
|
||||||
|
}
|
||||||
|
Text(value = rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
|
||||||
|
internal fun renderMessage(m: TuiMessage): String = when (m) {
|
||||||
|
is TuiMessage.System -> " ── ${m.text}\n"
|
||||||
|
is TuiMessage.User -> " > ${m.text}\n"
|
||||||
|
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||||
|
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||||
|
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||||
|
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.layout.KeyEvent
|
||||||
|
import com.jakewharton.mosaic.layout.drawBehind
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя строка ввода с курсором.
|
||||||
|
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
|
||||||
|
*
|
||||||
|
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
|
||||||
|
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
|
||||||
|
*
|
||||||
|
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun InputLine(state: AppState) {
|
||||||
|
val text by state.input.collectAsState()
|
||||||
|
val cursor by state.cursor.collectAsState()
|
||||||
|
val streaming by state.streaming.collectAsState()
|
||||||
|
val cursorPos = cursor.coerceIn(0, text.length)
|
||||||
|
val before = text.substring(0, cursorPos)
|
||||||
|
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||||
|
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||||
|
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||||
|
val prompt = if (streaming) " ⋯" else " >"
|
||||||
|
|
||||||
|
Text(
|
||||||
|
value = "$prompt $before|$cursorChar|${after}",
|
||||||
|
modifier = Modifier
|
||||||
|
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||||
|
.drawBehind {
|
||||||
|
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||||
|
state.input.let { /* touch */ }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
|
||||||
|
*
|
||||||
|
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
|
||||||
|
* на корневой обработчик ([pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||||
|
if (ev.alt || ev.ctrl) return false
|
||||||
|
return when (ev.key) {
|
||||||
|
"Enter" -> state.submitInput() != null
|
||||||
|
"Backspace" -> { state.inputBackspace(); true }
|
||||||
|
"Delete" -> { state.inputDelete(); true }
|
||||||
|
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||||
|
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||||
|
"Home" -> { state.inputCursorHome(); true }
|
||||||
|
"End" -> { state.inputCursorEnd(); true }
|
||||||
|
else -> {
|
||||||
|
val s = ev.key
|
||||||
|
if (s.length == 1) { state.inputInsert(s); true }
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
|
||||||
|
* вызвали [createConversation], и отдаёт заранее сконструированные
|
||||||
|
* [FakeConversation].
|
||||||
|
*/
|
||||||
|
internal class FakeAgent(
|
||||||
|
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
||||||
|
) : Agent {
|
||||||
|
override val id: String = "fake"
|
||||||
|
var createCount: Int = 0
|
||||||
|
private set
|
||||||
|
val conversations = mutableListOf<FakeConversation>()
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
|
createCount++
|
||||||
|
val c = conversationFactory()
|
||||||
|
conversations += c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): Conversation? =
|
||||||
|
conversations.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean =
|
||||||
|
conversations.removeAll { it.id == id }
|
||||||
|
|
||||||
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||||
|
conversations.toList()
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
|
||||||
|
* [Event] через общий [MutableSharedFlow]. Используется в тестах
|
||||||
|
* [TuiBackend] для проверки маршрутизации событий в UI.
|
||||||
|
*/
|
||||||
|
internal class FakeConversation(
|
||||||
|
override val id: String = "fake-conv",
|
||||||
|
override val title: String? = null,
|
||||||
|
) : Conversation {
|
||||||
|
override val isSupportImageInput: Boolean = false
|
||||||
|
override val isSupportImageOutput: Boolean = false
|
||||||
|
override val isTemporal: Boolean = false
|
||||||
|
override val updatedAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
val sent = mutableListOf<List<Content>>()
|
||||||
|
val sentContexts = mutableListOf<MessageContext?>()
|
||||||
|
var closed: Boolean = false
|
||||||
|
private set
|
||||||
|
var interrupted: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
|
||||||
|
fun emit(e: Event) { eventsFlow.tryEmit(e) }
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
|
sent += content
|
||||||
|
sentContexts += context
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() { interrupted = true }
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<Event> = eventsFlow
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||||
|
|
||||||
|
override fun close() { closed = true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
|
||||||
|
* для передачи в `start` — фоновые подписки должны жить параллельно с
|
||||||
|
* телом теста и автоматически отменяться по его завершении. Иначе
|
||||||
|
* бесконечный collect на `agent.events()` завешивает runTest на 60s
|
||||||
|
* `UncompletedCoroutinesError`.
|
||||||
|
*
|
||||||
|
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
|
||||||
|
* потому что `backgroundScope` использует свой диспетчер, который не
|
||||||
|
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
|
||||||
|
* те задачи, что готовы к запуску сейчас.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TuiBackendTest {
|
||||||
|
|
||||||
|
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
|
||||||
|
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start posts connected system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains(cfg.server) },
|
||||||
|
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("привет")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(1, agent.createCount, "должен быть один createConversation")
|
||||||
|
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
|
||||||
|
// temp=false — обычный (не временный) диалог: персистится на сервере
|
||||||
|
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
|
||||||
|
// state знает id и title нового диалога
|
||||||
|
assertEquals("fake-conv", state.currentConversationId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `second onUserMessage reuses same conversation`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("раз")
|
||||||
|
runCurrent()
|
||||||
|
backend.onUserMessage("два")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
|
||||||
|
assertEquals(2, agent.conversations.first().sent.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AppendText appends to current assistant streaming chunk`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "Привет"))
|
||||||
|
conv.emit(Event.AppendText(now, ", мир"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
|
||||||
|
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
|
||||||
|
assertEquals("Привет, мир", assistantMsgs.single().text)
|
||||||
|
assertTrue(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `End event finalizes assistant and stops streaming`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "ответ"))
|
||||||
|
conv.emit(Event.End(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val last = state.messages.value.last()
|
||||||
|
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
|
||||||
|
assertEquals("ответ", (last as TuiMessage.Assistant).text)
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Interrupted event clears streaming and posts system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "часть ответа"))
|
||||||
|
conv.emit(Event.Interrupted(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains("прервано") },
|
||||||
|
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||||
|
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||||
|
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
|
||||||
|
assertEquals(1, toolMsgs.size)
|
||||||
|
assertEquals("echo", toolMsgs.single().toolName)
|
||||||
|
assertEquals("""{"x":1}""", toolMsgs.single().args)
|
||||||
|
assertEquals(1, resultMsgs.size)
|
||||||
|
assertEquals("ok", resultMsgs.single().result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Error event posts system message and clears streaming`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.Error(date = now, message = "boom"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `onUserMessage does not swallow exceptions — state stays consistent`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
|
||||||
|
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `StartReasoning posts thinking system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartReasoning(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(sysMsgs.any { it.text.contains("думаю") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<List<Content>>.flattenText(): List<String> =
|
||||||
|
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
|
AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
|
||||||
|
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
|
||||||
|
* с понятной ошибкой.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = null
|
||||||
|
|
||||||
|
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "pw.binom.agentik"
|
||||||
|
|
||||||
|
// projectVersion определяется ниже как val, чтобы subprojects могли его
|
||||||
|
// прочитать через rootProject.extra["projectVersion"].
|
||||||
|
|
||||||
|
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
||||||
|
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
|
||||||
|
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
|
||||||
|
// gradle.properties). Без версии maven-publish падает с
|
||||||
|
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
||||||
|
// это известный gotcha: subprojects читают rootProject.version ДО того, как
|
||||||
|
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
|
||||||
|
// eagerly, и subprojects получают готовую строку.
|
||||||
|
val projectVersion: String = providers.gradleProperty("version")
|
||||||
|
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
|
||||||
|
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
|
||||||
|
version = projectVersion
|
||||||
|
extra["projectVersion"] = projectVersion
|
||||||
|
|
||||||
|
// Home Nexus URL/creds — передаются через -Pbinom.repo.* из CI/CD workflow
|
||||||
|
// (.gitea/workflows/release.yml). Локально для дебага:
|
||||||
|
// ./gradlew publish -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
|
||||||
|
// Без -P URL падает на дефолтный placeholder (заглушка для локальной разработки).
|
||||||
|
val binomRepoUrl = (findProperty("binom.repo.url") as String? ?: "http://nexus.xx/repository/caffeine/").toString()
|
||||||
|
val binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
|
||||||
|
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
|
||||||
|
|
||||||
|
// Per-module POM description. Один источник истины — карта ниже,
|
||||||
|
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
|
||||||
|
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
|
||||||
|
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
|
||||||
|
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
|
||||||
|
// beforeEvaluate — поздно).
|
||||||
|
val moduleDescriptions: Map<String, String> = mapOf(
|
||||||
|
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
|
||||||
|
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
|
||||||
|
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
||||||
|
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
||||||
|
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
|
||||||
|
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||||
|
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||||
|
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||||
|
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
||||||
|
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
||||||
|
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||||
|
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||||
|
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||||
|
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
||||||
|
)
|
||||||
|
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
group = rootProject.group
|
||||||
|
|
||||||
|
// KMP-плагин читает project.version на ранней стадии evaluation — ДО того
|
||||||
|
// как сработает внешний subprojects-блок. Если version ещё "unspecified",
|
||||||
|
// publication 'kotlinMultiplatform' создаётся с пустой version, и тогда
|
||||||
|
// maven-publish падает с 'InvalidMavenPublicationException: version cannot
|
||||||
|
// be empty'. Поэтому:
|
||||||
|
// 1) eagerly переопределяем version в rootProject.extra (см. выше)
|
||||||
|
// 2) на КАЖДЫЙ subproject вешаем beforeEvaluate, который выставляет
|
||||||
|
// version до того, как KMP-плагин начнёт создавать publications.
|
||||||
|
|
||||||
|
// per-module POM description берётся из rootProject.extra["moduleDescriptions"]
|
||||||
|
// (см. корень build.gradle.kts); добавлять новый модуль — туда + README.md.
|
||||||
|
|
||||||
|
// beforeEvaluate срабатывает ДО apply плагинов в build.gradle.kts модуля, так
|
||||||
|
// что version/description уже валидны, когда KMP-плагин начинает создавать
|
||||||
|
// publications.
|
||||||
|
beforeEvaluate {
|
||||||
|
description = (rootProject.extra["moduleDescriptions"] as Map<String, String>)[project.name]
|
||||||
|
?: "agentik module: ${project.name}"
|
||||||
|
version = rootProject.extra["projectVersion"] as String
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(plugin = "maven-publish")
|
||||||
|
|
||||||
|
extensions.configure<PublishingExtension>("publishing") {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = "caffeine"
|
||||||
|
url = uri(binomRepoUrl)
|
||||||
|
isAllowInsecureProtocol = true
|
||||||
|
credentials {
|
||||||
|
username = binomRepoUser
|
||||||
|
password = binomRepoPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-subproject POM-метаданные (name, scm, licenses, developers).
|
||||||
|
// Также явно выставляем version/group для каждой публикации. В KMP-модулях
|
||||||
|
// (особенно JVM-only с одним jvm() target) kotlin-multiplatform plugin
|
||||||
|
// создаёт publication 'kotlinMultiplatform' на ранней стадии evaluation,
|
||||||
|
// когда project.version ещё 'unspecified'. Простое присваивание
|
||||||
|
// subprojects { version = ... } НЕ перезаписывает уже зафиксированную
|
||||||
|
// version в publication → InvalidMavenPublicationException в CI.
|
||||||
|
// Явная установка version здесь гарантирует, что публикация всегда
|
||||||
|
// использует актуальное значение из rootProject.extra.
|
||||||
|
publications.withType<MavenPublication>().configureEach {
|
||||||
|
groupId = rootProject.group.toString()
|
||||||
|
artifactId = project.name
|
||||||
|
version = rootProject.extra["projectVersion"] as String
|
||||||
|
|
||||||
|
pom {
|
||||||
|
name = project.name
|
||||||
|
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
|
||||||
|
?: "agentik module: ${project.name}"
|
||||||
|
url = "https://git.binom.pw/subochev/agentik"
|
||||||
|
|
||||||
|
licenses {
|
||||||
|
license {
|
||||||
|
name = "Apache-2.0"
|
||||||
|
url = "https://www.apache.org/licenses/LICENSE-2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
developers {
|
||||||
|
developer {
|
||||||
|
id = "subochev"
|
||||||
|
name = "Subochev Alexey"
|
||||||
|
url = "https://git.binom.pw/subochev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scm {
|
||||||
|
connection = "scm:git:https://git.binom.pw/subochev/agentik.git"
|
||||||
|
developerConnection = "scm:git:ssh://git@git.binom.pw/subochev/agentik.git"
|
||||||
|
url = "https://git.binom.pw/subochev/agentik"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
||||||
|
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
||||||
|
интерфейсы `:proto`:
|
||||||
|
|
||||||
|
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
||||||
|
- `AgentClient` — список и lifecycle диалогов.
|
||||||
|
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
||||||
|
`getMessages()`, `rename()`, `close()`.
|
||||||
|
- Внутренний парсер SSE → `Flow<Event>`.
|
||||||
|
|
||||||
|
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
||||||
|
с теми же типами, что и сервер. Один и тот же клиент работает на
|
||||||
|
JVM, iOS, macOS, Linux, Windows.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:agentik-cli` — REPL.
|
||||||
|
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
||||||
|
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets.commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ваш код:
|
||||||
|
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||||
|
val conv = agent.createConversation(title = "test")
|
||||||
|
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is Event.AppendText -> print(event.body)
|
||||||
|
is Event.End -> println("\n--- end ---")
|
||||||
|
is Event.Error -> error("agent error: ${event.message}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||||
|
|
||||||
|
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||||
|
|
||||||
|
## Примеры API
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// список диалогов
|
||||||
|
agent.getConversations().collect { println(it.id to it.title) }
|
||||||
|
|
||||||
|
// live-подписка на события отдельного диалога
|
||||||
|
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||||
|
|
||||||
|
// прерывание текущего хода
|
||||||
|
conversation.interrupt()
|
||||||
|
|
||||||
|
// история
|
||||||
|
conversation.getMessages(offset = 0).collect { msg ->
|
||||||
|
when (msg) {
|
||||||
|
is Message.UserMessage -> println("user: ${msg.content}")
|
||||||
|
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :client:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||||
|
401/404.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого LLM-кода. Это просто клиент.
|
||||||
|
- Никакого persistent state. История хранится у сервера, клиент её
|
||||||
|
запрашивает через `getMessages` или подписывается через `events`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||||
|
но клиент совместим с любым сервером, который держит wire-контракт
|
||||||
|
`:server`.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
|
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||||
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
|
||||||
|
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
|
||||||
|
// без iOS. См. agentik-cli/build.gradle.kts.
|
||||||
|
jvm()
|
||||||
|
listOf(
|
||||||
|
macosX64(),
|
||||||
|
macosArm64(),
|
||||||
|
linuxX64(),
|
||||||
|
linuxArm64(),
|
||||||
|
mingwX64(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.test.host)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.sse)
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// :client — это библиотека, не executable. Native-бинари объявляются
|
||||||
|
// в :agentik-cli (он зависит от :client и реально предоставляет main).
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.delete
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
|
*
|
||||||
|
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
||||||
|
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
||||||
|
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
||||||
|
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
||||||
|
* решает, что делать.
|
||||||
|
*/
|
||||||
|
internal class AgentClient(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
override val id: String,
|
||||||
|
) : Agent {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
|
runBlocking {
|
||||||
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(RequestCreateConversation(temp))
|
||||||
|
}.body()
|
||||||
|
ConversationClient(httpClient = httpClient, baseUrl = agentUrl, snapshot = snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): Conversation? {
|
||||||
|
val response = httpClient.get("$agentUrl/conversations/$id")
|
||||||
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
|
val snapshot = response.body<ConversationSnapshot>()
|
||||||
|
return ConversationClient(httpClient, agentUrl, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
|
val response = httpClient.delete("$agentUrl/conversations/$id")
|
||||||
|
return response.status == HttpStatusCode.NoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
|
||||||
|
val snapshots = httpClient.get("$agentUrl/conversations") {
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}.body<List<ConversationSnapshot>>()
|
||||||
|
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||||
|
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
||||||
|
* (модуль `:server`).
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val client = AgentikAgent(
|
||||||
|
* id = "my-agent",
|
||||||
|
* baseUrl = "http://localhost:8080/agentik",
|
||||||
|
* )
|
||||||
|
* val conv = client.createConversation(temp = false)
|
||||||
|
* conv.send(listOf(Content.Text("hi")))
|
||||||
|
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
||||||
|
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||||
|
* конфига).
|
||||||
|
*
|
||||||
|
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
||||||
|
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
||||||
|
*/
|
||||||
|
fun AgentikAgent(
|
||||||
|
id: String,
|
||||||
|
baseUrl: String,
|
||||||
|
httpClient: HttpClient = defaultAgentikHttpClient(),
|
||||||
|
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
||||||
|
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
||||||
|
*
|
||||||
|
* **Платформы:**
|
||||||
|
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
||||||
|
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
||||||
|
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
||||||
|
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
||||||
|
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
||||||
|
*
|
||||||
|
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
||||||
|
* Реализация — в [HttpClientFactory.kt].
|
||||||
|
*/
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.patch
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [Conversation]. Ходит в `:server`-фасад под
|
||||||
|
* `/conversations/{id}/...`.
|
||||||
|
*
|
||||||
|
* `id` отдаётся синхронно (он в [snapshot], доступном сразу). Остальные
|
||||||
|
* поля (`title`, `isSupportImageInput`, ...) — тоже из snapshot. [snapshot]
|
||||||
|
* обновляется после [rename] (сервер возвращает свежий).
|
||||||
|
*
|
||||||
|
* **Caveat — `updatedAt`:** сервер бампит `updatedAt` на каждый
|
||||||
|
* `send`/`rename`, но клиент узнает об этом только при следующем
|
||||||
|
* `rename` или `getConversation`. Если нужна свежая свежесть после
|
||||||
|
* `send` — перезапроси через `Agent.getConversation(id)`.
|
||||||
|
*
|
||||||
|
* [close] — локальный no-op: сервер держит диалог живым. Удалить —
|
||||||
|
* через `Agent.deleteConversation(id)`.
|
||||||
|
*/
|
||||||
|
internal class ConversationClient(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private var snapshot: ConversationSnapshot,
|
||||||
|
) : Conversation {
|
||||||
|
|
||||||
|
override val id: String get() = snapshot.id
|
||||||
|
override val title: String? get() = snapshot.title
|
||||||
|
override val isSupportImageInput: Boolean get() = snapshot.isSupportImageInput
|
||||||
|
override val isSupportImageOutput: Boolean get() = snapshot.isSupportImageOutput
|
||||||
|
override val isTemporal: Boolean get() = snapshot.isTemporal
|
||||||
|
override val updatedAt: Instant get() = snapshot.updatedAt
|
||||||
|
|
||||||
|
private val convUrl: String get() = "$baseUrl/conversations/$id"
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) {
|
||||||
|
val updated = httpClient.patch(convUrl) {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(RequestRename(title))
|
||||||
|
}.body<ConversationSnapshot>()
|
||||||
|
snapshot = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
|
httpClient.post("$convUrl/messages") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SendPayload(content, context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() {
|
||||||
|
httpClient.post("$convUrl/interrupt")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<Event> = flow {
|
||||||
|
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
||||||
|
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
||||||
|
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
||||||
|
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||||
|
httpClient.get("$convUrl/messages") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}.body()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// Локальный no-op: диалог на сервере живёт, пока не вызван
|
||||||
|
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SendPayload(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-снимок [pw.binom.agentik.proto.Conversation] — те же поля, что у
|
||||||
|
* интерфейса, но без методов. Зеркалит
|
||||||
|
* [pw.binom.agentik.server.ConversationSnapshot]. Дубликат сознательно:
|
||||||
|
* переедем в общий `:wire`, когда появится больше типов.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ConversationSnapshot(
|
||||||
|
val id: String,
|
||||||
|
val isSupportImageInput: Boolean,
|
||||||
|
val isSupportImageOutput: Boolean,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
val title: String? = null,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestCreateConversation(val temp: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class RequestRename(val title: String)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
||||||
|
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
||||||
|
*
|
||||||
|
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
||||||
|
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
||||||
|
*/
|
||||||
|
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||||
|
engine { requestTimeout = 0 }
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зеркалит [pw.binom.agentik.server.InstantSerializer]. Дублируем сознательно:
|
||||||
|
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||||
|
*/
|
||||||
|
internal object InstantSerializer : KSerializer<Instant> {
|
||||||
|
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
|
||||||
|
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
|
||||||
|
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
|
encoder.encodeString(value.toString())
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Instant =
|
||||||
|
Instant.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-конфиг клиента. Должен **точно** совпадать с серверным `agentikJson` —
|
||||||
|
* один и тот же wire-формат с обеих сторон.
|
||||||
|
*/
|
||||||
|
internal val agentikJson: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(Instant::class, InstantSerializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
import io.ktor.utils.io.readUTF8Line
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный парсер Server-Sent Events, читающий канал до EOF и эмиттящий
|
||||||
|
* собранный `data:`-пейлоад каждого события. Достаточно для нашего wire-формата:
|
||||||
|
* сервер шлёт `data: <json>\n\n`, имя события и прочие поля не используются.
|
||||||
|
*
|
||||||
|
* Формат (см. WHATWG):
|
||||||
|
* event: foo — игнор (у нас нет имён событий)
|
||||||
|
* data: {"k":1} — накапливается, многострочный `data:` склеивается через '\n'
|
||||||
|
* :comment — игнор
|
||||||
|
* id:/retry:/<blank> — пустая строка = граница события; всё остальное игнор
|
||||||
|
*
|
||||||
|
* Поток закрывается, когда канал доходит до EOF; накопленный `data` (если есть)
|
||||||
|
* эмитится как финальный ивент.
|
||||||
|
*/
|
||||||
|
internal fun readSse(channel: ByteReadChannel): Flow<String> = flow {
|
||||||
|
val data = StringBuilder()
|
||||||
|
while (!channel.isClosedForRead) {
|
||||||
|
val line = channel.readUTF8Line() ?: break
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (data.isNotEmpty()) {
|
||||||
|
emit(data.toString())
|
||||||
|
data.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line.startsWith("data: ") -> {
|
||||||
|
if (data.isNotEmpty()) data.append('\n')
|
||||||
|
data.append(line.removePrefix("data: "))
|
||||||
|
}
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
if (data.isNotEmpty()) data.append('\n')
|
||||||
|
data.append(line.removePrefix("data:"))
|
||||||
|
}
|
||||||
|
// event:, id:, retry:, ":" (comment) — игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.isNotEmpty()) emit(data.toString())
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||||
|
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||||
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||||
|
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||||
|
*
|
||||||
|
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
||||||
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
|
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||||
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
|
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
||||||
|
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||||
|
*
|
||||||
|
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||||
|
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||||
|
* инстанс мог бы утечь между запросами.
|
||||||
|
*/
|
||||||
|
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
||||||
|
setCapability(
|
||||||
|
HttpTimeoutCapability,
|
||||||
|
HttpTimeoutConfig(
|
||||||
|
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.response.respondBytesWriter
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.utils.io.writeStringUtf8
|
||||||
|
import io.ktor.utils.io.readUTF8Line
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
||||||
|
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
|
||||||
|
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
||||||
|
* перед каждым read-стримом.
|
||||||
|
*
|
||||||
|
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
|
||||||
|
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
|
||||||
|
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
|
||||||
|
* сообщение доходит.
|
||||||
|
*
|
||||||
|
* Читаем строки пока не найдём "data: done" или пока не сработает
|
||||||
|
* [withTimeout] (18 с — запас над server delay 20 с).
|
||||||
|
*/
|
||||||
|
class SseTimeoutTest {
|
||||||
|
|
||||||
|
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/sse") {
|
||||||
|
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||||
|
writeStringUtf8("data: hello\n\n")
|
||||||
|
flush()
|
||||||
|
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
|
||||||
|
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
|
||||||
|
delay(17_000)
|
||||||
|
writeStringUtf8("data: done\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val received = mutableListOf<String>()
|
||||||
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
|
header("Accept", "text/event-stream")
|
||||||
|
noSseReadTimeout()
|
||||||
|
}.execute { resp ->
|
||||||
|
val ch = resp.bodyAsChannel()
|
||||||
|
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
||||||
|
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
|
||||||
|
val deadline = 19_000L
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - start < deadline) {
|
||||||
|
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
received.add(line)
|
||||||
|
}
|
||||||
|
if (line == "data: done") break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
|
||||||
|
assertTrue(
|
||||||
|
received.contains("data: done"),
|
||||||
|
"должно получить done (SSE read не должен падать на 15 с): $received",
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
received.any { it == "<timeout>" },
|
||||||
|
"SSE read упал в timeout (capability не сработал): $received",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
|
||||||
|
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
||||||
|
* Сервер держит stream 17 с; если клиент не выставил capability —
|
||||||
|
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
||||||
|
* дожидаясь "done".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/sse") {
|
||||||
|
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||||
|
writeStringUtf8("data: hello\n\n")
|
||||||
|
flush()
|
||||||
|
delay(17_000)
|
||||||
|
writeStringUtf8("data: done\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
|
header("Accept", "text/event-stream")
|
||||||
|
// НАМЕРЕННО без noSseReadTimeout.
|
||||||
|
}.execute { resp ->
|
||||||
|
val ch = resp.bodyAsChannel()
|
||||||
|
// Читаем строки, пока не придёт "data: done" — без capability
|
||||||
|
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
|
||||||
|
while (true) {
|
||||||
|
val line = ch.readUTF8Line() ?: break
|
||||||
|
if (line == "data: done") break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("без capability клиент должен словить HttpRequestTimeoutException")
|
||||||
|
} catch (e: HttpRequestTimeoutException) {
|
||||||
|
val elapsed = System.currentTimeMillis() - start
|
||||||
|
assertTrue(
|
||||||
|
elapsed in 14_000..17_000,
|
||||||
|
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Agentik — архитектура
|
||||||
|
|
||||||
|
> Полевые заметки о структуре проекта на текущий момент.
|
||||||
|
> Подробности запуска — `docs/STANDALONE.md`, чек-лист native-сборки —
|
||||||
|
> `NATIVE-COMPATIBILITY.md`, открытые вопросы — `IRC-QUESTIONS.md`.
|
||||||
|
|
||||||
|
## 1. Контекст
|
||||||
|
|
||||||
|
agentik — runtime агента. Ядро на собственном протоколе (`:proto`),
|
||||||
|
HTTP/SSE-фасад в `:server`, runtime-контейнер в `:standalone`. Раньше
|
||||||
|
фасадов было два (AG-UI + A2A); AG-UI убран как устаревший,
|
||||||
|
`a2a-server` остаётся заготовкой в `libs.versions.toml` и подключён в
|
||||||
|
`:standalone` `build.gradle.kts`, но в `Main.kt` пока не монтируется.
|
||||||
|
|
||||||
|
LLM — за интерфейсом `pw.binom.litert.LiteLlm`. Реализации:
|
||||||
|
`pw.binom.litert.openai` (HTTP/JSON поверх OpenAI-API, любая
|
||||||
|
совместимая endpoint) и `pw.binom.litert.google` (встроенный
|
||||||
|
LiteRT-LM движок для `.litertlm`/`.task` моделей).
|
||||||
|
|
||||||
|
MCP — `io.modelcontextprotocol:kotlin-sdk-client` (KMP). Подключаются
|
||||||
|
stdio + streamable-HTTP серверы, тулы оборачиваются в `LiteTool`.
|
||||||
|
|
||||||
|
## 2. Модули
|
||||||
|
|
||||||
|
```
|
||||||
|
agentik
|
||||||
|
├─ :proto KMP jvm + native. Интерфейсы Agent/Conversation, Content,
|
||||||
|
│ Message, Event, AgentEvent. @SerialName
|
||||||
|
│ дискриминаторы snake_case на проводе.
|
||||||
|
├─ :server KMP jvm + native. HTTP/SSE фасад `Route.agentikAgent(agent,
|
||||||
|
│ path = "/agentik")`. Json DTO — свой
|
||||||
|
│ Snapshot-тип, чтобы протокол оставался
|
||||||
|
│ сериализационно-чистым.
|
||||||
|
├─ :client JVM-only (пока). Ktor-клиент к фасаду `:server`,
|
||||||
|
│ подключающий удалённый агент как
|
||||||
|
│ локальный `Agent`.
|
||||||
|
└─ :standalone KMP jvm (linuxX64 — в работе). Runnable-контейнер:
|
||||||
|
SqliteStores + ChatAgent + MCP + LiteLlm,
|
||||||
|
поднимает Ktor (CIO) на AGENTIK_PORT.
|
||||||
|
```
|
||||||
|
|
||||||
|
Каталог версий — `gradle/libs.versions.toml`. Из внешних — только
|
||||||
|
`pw.binom.litert.*`, `pw.binom.a2a.*`, `app.cash.sqldelight`,
|
||||||
|
`io.ktor:*`, `io.modelcontextprotocol:kotlin-sdk-client`,
|
||||||
|
`org.jetbrains.kotlinx:*`.
|
||||||
|
|
||||||
|
## 3. `:proto` — интерфейсы
|
||||||
|
|
||||||
|
`Agent` (см. `proto/src/commonMain/.../Agent.kt`):
|
||||||
|
- `id: String`
|
||||||
|
- `createConversation(temp: Boolean): Conversation`
|
||||||
|
- `suspend getConversation(id): Conversation?`
|
||||||
|
- `suspend getConversations(offset, limit): List<Conversation>`
|
||||||
|
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`.
|
||||||
|
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
|
||||||
|
- `deleteConversation(id): Boolean`
|
||||||
|
|
||||||
|
`Conversation`:
|
||||||
|
- `isSupportImageInput / Output / isTemporal: Boolean`
|
||||||
|
- `updatedAt: Instant`
|
||||||
|
- `send(content: List<Content>)` — write-only, ничего не возвращает.
|
||||||
|
- `interrupt()` — отмена активного хода.
|
||||||
|
- `events(after): Flow<Event>` — live, replay-free.
|
||||||
|
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
|
||||||
|
- `rename(title)` — мутация, бампит `updatedAt`.
|
||||||
|
- `AutoCloseable` — `close()` идемпотентен.
|
||||||
|
|
||||||
|
`Content = Text(body) | Image(data, mime)`.
|
||||||
|
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
|
||||||
|
`Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error`.
|
||||||
|
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title)`.
|
||||||
|
|
||||||
|
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
|
||||||
|
лишь рендерит Event-stream и кэширует историю.
|
||||||
|
|
||||||
|
## 4. `:standalone` — runtime-контейнер
|
||||||
|
|
||||||
|
Точка входа `pw.binom.agentik.standalone.MainKt`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Main.kt
|
||||||
|
├─ LlmConfig.fromEnv() → LlmConfig(backend, openai, google, systemPrompt)
|
||||||
|
├─ llmConfig.createLlm() → LiteLlm (рефлексия для google)
|
||||||
|
├─ SqliteStores.open(dbPath) → ConversationStore + MessageStore + WorkingMemoryStore
|
||||||
|
├─ McpRegistry.fromConfig(...) → список NamedTool из всех MCP-серверов
|
||||||
|
├─ ChatAgent(stores, llm, llmConfig, tools)
|
||||||
|
└─ embeddedServer(CIO, port) { agentikAgent(agent, "/agentik") }
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ChatAgent`** — реализация `Agent`:
|
||||||
|
- `live: Map<String, ChatConversation>` — in-memory кэш активных сессий.
|
||||||
|
- `createConversation(temp)`: для temp — только кэш; для persistent —
|
||||||
|
`upsert(conversation)` + `workingMemory.append(System(systemPrompt))`
|
||||||
|
атомарно, потом `live[id] = ChatConversation(...)`.
|
||||||
|
- `getConversation(id)`: кэш → store (cold-load). Переоткрытие восстанавливает
|
||||||
|
`LiteConversation` из `workingMemory.list(id)` (см. ниже).
|
||||||
|
- `send`/`events`/`interrupt`/`delete`/`rename` проксируются в `ChatConversation`.
|
||||||
|
|
||||||
|
**`ChatConversation`** — реализация `Conversation`:
|
||||||
|
- На каждый ход: `workingMemory.append(User)` → `liteConv.sendStreamContents(...)`
|
||||||
|
→ стрим `LiteDelta` → клиенту (AppendText / ToolCall / ToolResult).
|
||||||
|
- На завершение стрима: `messageStore.append(Assistant)` + `workingMemory.append(Assistant)`
|
||||||
|
+ `conversationStore.touch(id)`.
|
||||||
|
- Tool-loop: на `delta.toolCalls` → emit `Event.ToolCall` → execute
|
||||||
|
(`tool.invoke(argsJson)`) → emit `Event.ToolResult` →
|
||||||
|
`liteConv.addToolResult(callId, name, result)` → продолжение стрима.
|
||||||
|
- На ошибку хода (init/стрим LLM): `failTurn` → `messageStore.append(Error)`
|
||||||
|
(audit; в working_memory не пишется) + `Event.Error` + `Event.End`, живой
|
||||||
|
`LiteConversation` сбрасывается и пересобирается на следующем `send`.
|
||||||
|
- `LiteConversation` живёт **один на весь `ChatConversation`** для обоих
|
||||||
|
бэкендов: KV-cache движка сохраняется между ходами. Это контракт
|
||||||
|
litert-api, не только Google-specific.
|
||||||
|
- На `interrupt()` отменяется текущий `Job` и `liteConv.interrupt()`.
|
||||||
|
|
||||||
|
**`WorkingMemory`** — read-write контекст, который видит LLM:
|
||||||
|
- `System(text, sourceMessageId=null)` — системный промпт.
|
||||||
|
- `User/Assistant(sourceMessageId, content)` — снимки реальных сообщений.
|
||||||
|
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||||
|
summarization-вставка отложена (нужен дизайн-проработка).
|
||||||
|
|
||||||
|
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
||||||
|
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||||
|
update/delete кроме каскада из `ConversationStore.delete`.
|
||||||
|
|
||||||
|
## 5. Слои персистентности
|
||||||
|
|
||||||
|
`SqliteStores` (jvmMain) — три интерфейса из commonMain, одна
|
||||||
|
SQLite-БД. Схема (`jvmMain/sqldelight/`):
|
||||||
|
|
||||||
|
| таблица | поля | роль |
|
||||||
|
|---|---|---|
|
||||||
|
| `conversation` | id, title, is_temporal, created_at, updated_at | карточки диалогов |
|
||||||
|
| `message` | id, conversation_id, role, payload_json, created_at | аудит-хвост |
|
||||||
|
| `working_memory` | id, conversation_id, order_idx, source_message_id, payload_json, created_at | контекст LLM |
|
||||||
|
|
||||||
|
`Content` (text/image) и per-kind payload сериализуются в
|
||||||
|
`payload_json` через `kotlinx.serialization`. Maps в каскадное удаление
|
||||||
|
(`ConversationStore.delete` — одна транзакция).
|
||||||
|
|
||||||
|
## 6. Фасады
|
||||||
|
|
||||||
|
- **`:server` (HTTP+SSE)** — основной, KMP, Ktor Route extension.
|
||||||
|
`Route.agentikAgent(agent, path = "/agentik")` монтирует весь CRUD +
|
||||||
|
live event-stream. Подробнее — `docs/STANDALONE.md`.
|
||||||
|
- **A2A (`pw.binom.a2a:server`)** — заготовка в `libs.versions.toml`,
|
||||||
|
подключён в `:standalone` для совместимости с зависимостями через
|
||||||
|
`:client`, но **не монтируется в `Main.kt`**. Если/когда понадобится —
|
||||||
|
`Route.a2aAgent(...)` (как у agui в старом дизайне).
|
||||||
|
|
||||||
|
## 7. Конфигурация (env)
|
||||||
|
|
||||||
|
| env | назначение |
|
||||||
|
|---|---|
|
||||||
|
| `AGENTIK_PORT` | порт Ktor (default `8080`) |
|
||||||
|
| `AGENTIK_DB_PATH` | путь к SQLite (default `./agentik.db`) |
|
||||||
|
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта |
|
||||||
|
| `AGENTIK_LLM_BACKEND` | `openai` (default) или `google` |
|
||||||
|
| `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` | для backend=openai |
|
||||||
|
| `AGENTIK_GOOGLE_MODEL_PATH` / `AGENTIK_GOOGLE_THREADS` | для backend=google |
|
||||||
|
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop |
|
||||||
|
|
||||||
|
## 8. Что осталось за рамками v1
|
||||||
|
|
||||||
|
- Суммаризация `WorkingMemory.compact()` (пункт `compact(dropFromOrderIdx)` пуст).
|
||||||
|
- Image input (Content.Image принимается, но `LiteContentPart.Image`
|
||||||
|
сейчас дропается в Chat-цикле с warn-логом — модель видит только текст).
|
||||||
|
- Auth на фасаде.
|
||||||
|
- A2A transport facade в `Main.kt`.
|
||||||
@@ -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,357 @@
|
|||||||
|
# Standalone — рантайм агента agentik
|
||||||
|
|
||||||
|
`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0).
|
||||||
|
|
||||||
|
Этот документ описывает, как `standalone` собран и как его расширять.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что в коробке после `git clone`
|
||||||
|
|
||||||
|
```
|
||||||
|
:proto — единое ядро протокола (KMP, commonMain)
|
||||||
|
Agent / Conversation / Event / Message / Content / AgentEvent
|
||||||
|
stateful: агент сам хранит историю и working memory
|
||||||
|
|
||||||
|
:server — HTTP+SSE фасад :proto
|
||||||
|
public Route.agentikAgent(agent, path = "/agentik")
|
||||||
|
|
||||||
|
:skills — парсер и каталог навыков (KMP, commonMain + jvmMain-загрузчик)
|
||||||
|
SKILL.md (opencode frontmatter) / *.yaml, renderSystemPromptSection()
|
||||||
|
|
||||||
|
:standalone — JVM-рантайм с реальным LLM-агентом
|
||||||
|
ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google)
|
||||||
|
default 8080:
|
||||||
|
GET /health health check
|
||||||
|
POST /agentik/conversations создать диалог (201)
|
||||||
|
GET /agentik/conversations список
|
||||||
|
GET /agentik/conversations/{id} один диалог
|
||||||
|
PATCH /agentik/conversations/{id} переименовать
|
||||||
|
DELETE /agentik/conversations/{id} удалить (204)
|
||||||
|
POST /agentik/conversations/{id}/messages отправить user-сообщение (202)
|
||||||
|
POST /agentik/conversations/{id}/interrupt прервать текущий ход (202)
|
||||||
|
GET /agentik/conversations/{id}/messages страница истории
|
||||||
|
GET /agentik/conversations/{id}/events SSE live-события хода
|
||||||
|
GET /agentik/events SSE live-события агента
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная таблица эндпоинтов — в `docs/ARCHITECTURE.md` (раздел «:server»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Архитектура слоёв
|
||||||
|
|
||||||
|
```
|
||||||
|
клиенты транспорт
|
||||||
|
┌───────────────┐ ┌─────────────────────────────┐
|
||||||
|
│ Web / CLI / │ ──HTTP──► │ Route.agentikAgent(agent) │
|
||||||
|
│ desktop │ ──SSE───► │ :server (Ktor + Netty) │
|
||||||
|
│ │ └──────────────┬──────────────┘
|
||||||
|
└───────────────┘ │
|
||||||
|
▼
|
||||||
|
pw.binom.agentik.proto.Agent
|
||||||
|
(ChatAgent)
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
ChatConversation.send(content) agent.events / agent.getConversations
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 1. audit: append UserMessage │
|
||||||
|
│ 2. working_memory: append User │
|
||||||
|
│ 3. ensureLiteConversation: │
|
||||||
|
│ first turn → create from WM; │
|
||||||
|
│ next turns → reuse (KV-cache) │
|
||||||
|
│ 4. sendStreamContents → emit │
|
||||||
|
│ StartResponse / AppendText / │
|
||||||
|
│ End │
|
||||||
|
│ 5. audit + WM: append AssistantMessage│
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Conversation.events(after) Conversation.getMessages(after)
|
||||||
|
(live, no replay) (история)
|
||||||
|
```
|
||||||
|
|
||||||
|
Слои рантайма:
|
||||||
|
|
||||||
|
```
|
||||||
|
:standalone
|
||||||
|
├── persistence/ ← интерфейсы и records (commonMain, без зависимостей)
|
||||||
|
│ ConversationStore / MessageStore / WorkingMemoryStore
|
||||||
|
│ ConversationRecord / MessageRecord / WorkingMemoryEntry / Content
|
||||||
|
│ Payload.kt — JSON-сериализация
|
||||||
|
├── persistence/sqlite/ ← JVM: SQLDelight-схема + три SQLite-реализации
|
||||||
|
│ SqliteStores.open(path | inMemory)
|
||||||
|
│ src/jvmMain/sqldelight/.../*.sq
|
||||||
|
├── llm/ ← LlmConfig (env → OpenAI/Google), backend-agnostic
|
||||||
|
└── agent/ ← ChatAgent + ChatConversation (stateful, long-lived LiteConv)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевой инвариант: **разговор живёт внутри агента, а не в клиенте и не в транспорте.** Транспорт — лишь сериализатор: HTTP пишет в/читает из `:server`-эндпоинтов, SSE шлёт события. У них нет своего состояния диалога.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Точка входа: `pw.binom.agentik.standalone.MainKt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
|
||||||
|
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
|
||||||
|
|
||||||
|
val llmConfig = LlmConfig.fromEnv()
|
||||||
|
val llm = llmConfig.createLlm()
|
||||||
|
val stores = SqliteStores.open(dbPath = dbPath)
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
stores = stores,
|
||||||
|
llm = llm,
|
||||||
|
llmConfig = llmConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
val server = embeddedServer(Netty, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/health") { call.respondText("ok") }
|
||||||
|
agentikAgent(agent, path = "/agentik")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
|
agent.close(); stores.close(); llm.close()
|
||||||
|
})
|
||||||
|
server.start(wait = true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Один `ChatAgent` отвечает и за диалоги (`/agentik/conversations/...`), и за live-события (`/agentik/events`). Все три ресурса — БД, LLM, Netty — корректно закрываются в shutdown-хуке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Контракт `Agent` (от `pw.binom.agentik.proto`)
|
||||||
|
|
||||||
|
Реализация **обязана** уметь:
|
||||||
|
|
||||||
|
| метод | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `id: String` | идентификатор агента |
|
||||||
|
| `createConversation(temp: Boolean): Conversation` | новая сессия, `temp=true` — не персистить |
|
||||||
|
| `getConversation(id): Conversation?` | достать по id, `null` если нет |
|
||||||
|
| `deleteConversation(id): Boolean` | удалить |
|
||||||
|
| `getConversations(offset, limit)` | страница списка |
|
||||||
|
| `events(after: Instant): Flow<AgentEvent>` | live-события по множеству разговоров |
|
||||||
|
|
||||||
|
Реализация `Conversation`:
|
||||||
|
|
||||||
|
| метод | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `id: String` | идентификатор диалога |
|
||||||
|
| `title: String?` | заголовок (может быть `null`) |
|
||||||
|
| `isTemporal: Boolean` | `true` = не персистить (`temp=true` при создании) |
|
||||||
|
| `isSupportImageInput/Output: Boolean` | мультимодальные возможности (для v1 оба `false`) |
|
||||||
|
| `updatedAt: Instant` | последний `send`/`rename` |
|
||||||
|
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
|
||||||
|
| `interrupt()` | остановить текущий ход (best-effort) |
|
||||||
|
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) |
|
||||||
|
| `getMessages(after, offset, limit)` | страница истории |
|
||||||
|
| `rename(title)` | переименовать |
|
||||||
|
| `close()` | освободить ресурсы |
|
||||||
|
|
||||||
|
Главное: `send` ничего не возвращает. Чтобы получить события, нужно **отдельно** подписаться на `events(after)` ДО `send` либо сразу после — поток событий стартует с момента подписки, бэкфилл через `getMessages`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Persistence — dual-log
|
||||||
|
|
||||||
|
`ChatAgent` хранит каждую сессию в двух логически разных таблицах:
|
||||||
|
|
||||||
|
| таблица | назначение | мутации |
|
||||||
|
|---|---|---|
|
||||||
|
| `message` | append-only audit log. Все user/assistant/tool-call/tool-result/error сообщения. Никогда не редактируется (кроме каскадного `DELETE` при удалении диалога). | только `INSERT` |
|
||||||
|
| `working_memory` | mutable LLM-контекст. System-prompt + текущая история + (в v2) суммаризации. | `INSERT`, `compact(dropFromIdx, summary)` |
|
||||||
|
|
||||||
|
Маппинг `:proto.Message ↔ MessageRecord` живёт в `ChatConversation.kt` (`toProto`/`toStorage`) — сами `MessageRecord` намеренно НЕ зависят от `:proto`, чтобы можно было сменить транспорт без миграции таблиц.
|
||||||
|
|
||||||
|
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
|
||||||
|
|
||||||
|
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||||
|
|
||||||
|
### `MessageStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun append(record: MessageRecord)
|
||||||
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `WorkingMemoryStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
|
suspend fun list(conversationId: String): List<WorkingMemoryRow>
|
||||||
|
suspend fun clear(conversationId: String)
|
||||||
|
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
||||||
|
```
|
||||||
|
|
||||||
|
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
||||||
|
|
||||||
|
### `ConversationStore`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
suspend fun get(id: String): ConversationRecord?
|
||||||
|
suspend fun delete(id: String): Boolean // каскадно чистит message + working_memory
|
||||||
|
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||||
|
suspend fun rename(id: String, title: String?): Instant?
|
||||||
|
suspend fun touch(id: String, now: Instant)
|
||||||
|
```
|
||||||
|
|
||||||
|
Все три store — `AutoCloseable`; корневой ресурс `SqliteStores` закрывает их вместе с `SqlDriver`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LLM-конфигурация (`LlmConfig`)
|
||||||
|
|
||||||
|
`LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`:
|
||||||
|
|
||||||
|
- `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint.
|
||||||
|
- `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`).
|
||||||
|
|
||||||
|
### Общие env
|
||||||
|
|
||||||
|
| env | смысл | default |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTIK_PORT` | порт Netty (`/agentik`, `/health`) | `8080` |
|
||||||
|
| `AGENTIK_DB_PATH` | путь к SQLite-файлу | `./agentik.db` |
|
||||||
|
| `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` |
|
||||||
|
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
|
||||||
|
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) |
|
||||||
|
| `AGENTIK_SKILLS_DIR` | папка с навыками (рекурсивно; `SKILL.md` или `*.yaml`/`*.yml`) | не задано (навыков нет) |
|
||||||
|
|
||||||
|
### Backend `openai`
|
||||||
|
|
||||||
|
| env | смысл |
|
||||||
|
|---|---|
|
||||||
|
| `OPENAI_BASE_URL` | endpoint (например, `https://api.openai.com/v1` или `http://localhost:11434/v1`) — обязательно |
|
||||||
|
| `OPENAI_API_KEY` | ключ модели — обязательно |
|
||||||
|
| `OPENAI_MODEL` | имя модели (например, `gpt-4o-mini`, `myopenai/local/codding`) — обязательно |
|
||||||
|
|
||||||
|
### Backend `google` (on-device LiteRT-LM)
|
||||||
|
|
||||||
|
| env | смысл | default |
|
||||||
|
|---|---|---|
|
||||||
|
| `AGENTIK_GOOGLE_MODEL_PATH` | путь к `.task` или `.litertlm` модели — обязательно |
|
||||||
|
| `AGENTIK_GOOGLE_CACHE_DIR` | каталог кеша скомпилированных graph'ов | пусто (системный tmp) |
|
||||||
|
| `AGENTIK_GOOGLE_THREADS` | число CPU-потоков для движка | `4` |
|
||||||
|
|
||||||
|
`AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок).
|
||||||
|
|
||||||
|
### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов
|
||||||
|
|
||||||
|
`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`).
|
||||||
|
|
||||||
|
`LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Расширение
|
||||||
|
|
||||||
|
### Подключить тулы (MCP / in-agent)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 1) In-agent tool (нативный LiteTool):
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String =
|
||||||
|
"""{"type":"function","function":{"name":"echo","description":"Echo a string","parameters":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}}"""
|
||||||
|
override fun invoke(args: String): String = "echoed: $args"
|
||||||
|
}
|
||||||
|
val tools = listOf(NamedTool("echo", echoTool))
|
||||||
|
|
||||||
|
// 2) MCP (stdio / streamable HTTP) — конфиг в Claude Desktop-формате:
|
||||||
|
val mcp = McpConfig.fromEnv() // читает AGENTIK_MCP_CONFIG=path/to/mcp.json
|
||||||
|
val registry = McpRegistry.fromConfig(mcp) // стартует все серверы, лист LiteTool'ов
|
||||||
|
val tools = registry.namedTools // server__tool префикс автоматически
|
||||||
|
|
||||||
|
// 3) В обоих случаях:
|
||||||
|
val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools)
|
||||||
|
```
|
||||||
|
|
||||||
|
`LiteConversation` принимает `tools = ...` в `LiteConversationConfig`. На каждый `delta.toolCalls` из модели `ChatConversation.runTurn`:
|
||||||
|
|
||||||
|
1. Эмитит `Event.ToolCall(callId, toolName, argsJson)` клиенту (по SSE)
|
||||||
|
2. Записывает `MessageRecord.ToolCall` в audit + working memory (если не temp)
|
||||||
|
3. Вызывает `tool.invoke(argsJson)`
|
||||||
|
4. Эмитит `Event.ToolResult(resultId, resultText)`
|
||||||
|
5. Записывает `MessageRecord.ToolResult` (с `toolCallId = callId`)
|
||||||
|
6. Кормит `liteConv.addToolResult(callId, name, result)` в LiteConversation (KV-cache выживает между итерациями)
|
||||||
|
7. Цикл повторяется до `delta.toolCalls.isEmpty()`
|
||||||
|
|
||||||
|
ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`.
|
||||||
|
|
||||||
|
### Навыки (skills)
|
||||||
|
|
||||||
|
Навыки — это «лениво загружаемые» инструкции: в системный промпт попадают только **имя + краткое описание**, а полный текст модель достаёт сама, вызывая встроенный инструмент `read_skill`.
|
||||||
|
|
||||||
|
**Формат файла** (два варианта, оба читаются):
|
||||||
|
|
||||||
|
1. opencode-style `SKILL.md` — YAML-frontmatter + markdown-тело:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: backend:spring:db-base
|
||||||
|
description: MUST load before any database work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ... полный текст навыка ...
|
||||||
|
```
|
||||||
|
2. Голый YAML `*.yaml` / `*.yml` — поля `name`, `description`, опционально `body`:
|
||||||
|
```yaml
|
||||||
|
name: lint
|
||||||
|
description: Run the linter before committing.
|
||||||
|
body: |
|
||||||
|
# Lint
|
||||||
|
Run `./gradlew detekt`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Загрузка: `SkillLoader.loadDirectory(dir)` рекурсивно обходит `AGENTIK_SKILLS_DIR`, парсит файлы и возвращает `SkillCatalog` + список ошибок (битый файл не валит загрузку, дубликат имени — ошибка, выигрывает первый по пути).
|
||||||
|
|
||||||
|
**Что попадает в системный промпт** (`SkillCatalog.renderSystemPromptSection()`): блок `## Навыки` со списком `- **name**: description`. Тело навыка в промпт НЕ попадает.
|
||||||
|
|
||||||
|
**Инструмент `read_skill`** (`SkillReadTool`) регистрируется в `ChatAgent` автоматически, если каталог непустой; для модели он выглядит как обычная функция с аргументом `{"name": "<skill>"}`. Модель вызывает его по необходимости, результат возвращается как обычный tool-result (см. tool-loop ниже).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val skills = SkillLoader.loadDirectory(File(System.getenv("AGENTIK_SKILLS_DIR"))).catalog
|
||||||
|
val agent = ChatAgent(id, stores, llm, llmConfig, tools = mcpRegistry.namedTools, skills = skills)
|
||||||
|
```
|
||||||
|
|
||||||
|
Навык можно передать и напрямую «в тулзах» — `SkillReadTool` достаточно обернуть в `NamedTool(SkillReadTool.NAME, SkillReadTool(catalog))`, но при непустом `skills`-параметре это делается за вас.
|
||||||
|
|
||||||
|
### Добавить ещё один транспорт
|
||||||
|
|
||||||
|
Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол:
|
||||||
|
|
||||||
|
* `:server` (HTTP+SSE) — готов, `Route.agentikAgent(agent, path = "/agentik")`
|
||||||
|
* `:client` (HTTP-клиент) — готов, `AgentikAgent(id, baseUrl, httpClient)`
|
||||||
|
* `:irc-server` — IRC-фасад, в планах
|
||||||
|
|
||||||
|
Транспорт **не имеет доступа к внутренностям `ChatAgent`** — он видит только интерфейс `Agent`. Это и есть «транспортно-агностичное ядро».
|
||||||
|
|
||||||
|
### Добавить ещё один LLM-бэкенд
|
||||||
|
|
||||||
|
1. Описать `Config` data class с нужными полями.
|
||||||
|
2. Реализовать `LiteLlm`/`LiteConversation` поверх движка (см. litert-kmp — там уже есть `litert-google`, `litert-openai`, `litert-koog`).
|
||||||
|
3. Расширить `LlmConfig.createLlm()` веткой `when`.
|
||||||
|
|
||||||
|
### Заменить SQLite на Postgres / MongoDB / etc
|
||||||
|
|
||||||
|
1. Реализовать три store-интерфейса поверх нового движка.
|
||||||
|
2. Передать их в `ChatAgent` вместо `SqliteStores`.
|
||||||
|
3. Удалить (или оставить за `:standalone`-флагом) `:persistence/sqlite/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Что НЕ делает `standalone` сегодня
|
||||||
|
|
||||||
|
* **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации.
|
||||||
|
* **Нет авторизации.** Все эндпоинты открыты.
|
||||||
|
* **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
|
||||||
|
|
||||||
|
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
|
||||||
@@ -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,12 @@
|
|||||||
|
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
|
||||||
|
# Имя ключа специально НЕ 'version' — иначе Gradle-мерж gradle.properties и
|
||||||
|
# -Pversion= возьмёт default из gradle.properties. Передавай через CICD:
|
||||||
|
# ./gradlew ... -Pversion=$(git describe --tags)
|
||||||
|
# см. .gitea/workflows/release.yml (использует -Pversion=$GITHUB_REF_NAME).
|
||||||
|
agentik.version.default=0.1.0-SNAPSHOT
|
||||||
|
|
||||||
|
# KMP jvm target uses JDK 21 for both compilation and toolchain.
|
||||||
|
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
|
||||||
|
|
||||||
|
# Android SDK путь для будущей Android-сборки (пока не используется).
|
||||||
|
# sdk.dir=/home/subochev/Android/Sdk
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
[versions]
|
||||||
|
kotlin = "2.4.20"
|
||||||
|
kotlinx-serialization = "1.11.0"
|
||||||
|
kotlinx-coroutines = "1.11.0"
|
||||||
|
kotlinx-io = "0.8.0"
|
||||||
|
ktor = "3.1.3"
|
||||||
|
a2a = "1.0.0-SNAPSHOT"
|
||||||
|
kaml = "0.104.0"
|
||||||
|
litert = "8"
|
||||||
|
sqldelight = "2.3.2"
|
||||||
|
shadow = "8.3.5"
|
||||||
|
jvector = "3.0.6"
|
||||||
|
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||||
|
kotlin-logging = "3.0.5"
|
||||||
|
logback = "1.5.18"
|
||||||
|
mosaic = "0.18.0"
|
||||||
|
clikt = "5.0.3"
|
||||||
|
kotlinx-cli = "0.3.6"
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
|
||||||
|
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
|
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
|
||||||
|
a2a-shared = { module = "pw.binom.a2a:shared", version.ref = "a2a" }
|
||||||
|
a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" }
|
||||||
|
a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" }
|
||||||
|
|
||||||
|
# --- YAML для парсинга скилов (opencode-style frontmatter). KMP. ---
|
||||||
|
kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
|
||||||
|
|
||||||
|
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
|
||||||
|
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||||
|
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
|
||||||
|
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||||
|
|
||||||
|
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||||
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
|
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
|
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
|
|
||||||
|
# --- Ktor (сервер) ---
|
||||||
|
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
|
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
|
||||||
|
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
||||||
|
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
|
||||||
|
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
|
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||||
|
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# --- Model Context Protocol (MCP) ---
|
||||||
|
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||||
|
|
||||||
|
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
|
||||||
|
# https://ajalt.github.io/clikt/
|
||||||
|
# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module
|
||||||
|
# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...).
|
||||||
|
clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" }
|
||||||
|
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" }
|
||||||
|
|
||||||
|
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||||
|
# https://github.com/JakeWharton/mosaic
|
||||||
|
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
|
||||||
|
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
|
||||||
|
|
||||||
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
# --- commons ---
|
||||||
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
||||||
|
|
||||||
|
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
|
||||||
|
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
|
||||||
|
|
||||||
|
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
|
||||||
|
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||||
|
|
||||||
|
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||||
|
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
||||||
|
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
||||||
|
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||||
|
|
||||||
|
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||||
|
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
||||||
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+92
@@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -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+
|
||||||
|
заметок) и семантических запросов.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user