Compare commits
43 Commits
04276dec0e
...
4
| Author | SHA1 | Date | |
|---|---|---|---|
| c486c7f9ab | |||
| 9d310c5fd0 | |||
| 741ad8963d | |||
| 2634e0e204 | |||
| ac5d209fce | |||
| 25771a0c33 | |||
| 78cbe9b463 | |||
| 65e05612a1 | |||
| 850ee99cb6 | |||
| b5b21d146a | |||
| ee0b9d8341 | |||
| 9d826a4e81 | |||
| db3c49099c | |||
| ddd9d076c1 | |||
| 7a47131f6f | |||
| 9196102f68 | |||
| 6b12dd2c5b | |||
| eed1ab9a17 | |||
| b27ac622b4 | |||
| 14b46087dd | |||
| 098c97c7bd | |||
| 4b8e5bb0bd | |||
| d75289ac56 | |||
| 05f7b8fd04 | |||
| 8f616f359f | |||
| 5ad972767d | |||
| 0fdc12695e | |||
| e68db11aaa | |||
| b0bbc57880 | |||
| 408caee261 | |||
| 86eb0632e0 | |||
| c42a6027a4 | |||
| b1ae8bbd20 | |||
| e3f20f07d9 | |||
| 9fcb2da75d | |||
| 88af57182f | |||
| b2d5684192 | |||
| 31c4b1cfc4 | |||
| 202d379f5a | |||
| a3f82f875d | |||
| 5fbe865a29 | |||
| 87742cf60b | |||
| 6c53e1c87d |
@@ -0,0 +1,84 @@
|
||||
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
|
||||
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
|
||||
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
||||
#
|
||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||
# release.yml для требуемых переменных.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
|
||||
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
|
||||
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
build-jvm:
|
||||
name: JVM build + tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-agentik-
|
||||
|
||||
- name: Build + test (JVM only — самые быстрые таргеты)
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew jvmTest \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Build :standalone shadowJar (smoke — запускаемый артефакт)
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :standalone:shadowJar \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
test -f standalone/build/libs/standalone-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||
|
||||
- name: Build :agentik-cli shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :agentik-cli:shadowJar \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
||||
|
||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
|
||||
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
|
||||
# проверка сборки ограничивается test -f на jar (шаги выше).
|
||||
@@ -1,62 +1,49 @@
|
||||
# Триггерится при публикации релиза в Gitea. Делает две вещи:
|
||||
# 1. publish-libraries — публикует все KMP-библиотеки (jvm + все нативные таргеты)
|
||||
# в домашний Nexus-репозиторий "caffeine" через subochev/devops/publish action.
|
||||
# Переменные BINOM_REPO_URL / BINOM_REPO_USER / BINOM_REPO_PASSWORD задаются
|
||||
# в Gitea Action Variables для репозитория (Settings → Actions → Variables).
|
||||
# 2. build-standalone — собирает :standalone fatjar (shadowJar) и прикрепляет
|
||||
# standalone-<version>-all.jar к release как downloadable asset.
|
||||
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||
#
|
||||
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
|
||||
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
|
||||
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||
# выполняется локально из исходников (или руками через `./gradlew
|
||||
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||
# отдельно от этого workflow.
|
||||
#
|
||||
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
|
||||
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
|
||||
# версия берётся из тега каждый раз.
|
||||
#
|
||||
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
|
||||
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
|
||||
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
|
||||
name: release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
|
||||
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable
|
||||
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
|
||||
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
publish-libraries:
|
||||
name: Publish KMP libraries → caffeine Nexus
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# agentik не использует Android-target ни в одном модуле (все KMP-таргеты
|
||||
# JVM + native), поэтому Android SDK шаг из litert-kmp тут не нужен.
|
||||
|
||||
- name: Publish libraries
|
||||
- name: Publish libraries (all KMP targets, all modules) to Nexus
|
||||
uses: https://git.binom.pw/subochev/devops/publish@main
|
||||
with:
|
||||
version: ${{ gitea.ref_name }}
|
||||
|
||||
build-standalone:
|
||||
name: Build standalone fatjar
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-libraries
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Build shadowJar
|
||||
shell: bash
|
||||
run: ./gradlew :standalone:shadowJar -Dorg.gradle.jvmargs=-Xmx4096M --no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Compute version for filename
|
||||
id: ver
|
||||
shell: bash
|
||||
run: echo "version=${GITEA_REF_NAME}" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Attach standalone jar to release
|
||||
uses: https://github.com/softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
standalone/build/libs/standalone-*-all.jar
|
||||
standalone/build/libs/standalone-*-sources.jar
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
@@ -20,6 +20,9 @@ out/
|
||||
.cortexkit/
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
.tasks/
|
||||
|
||||
# Runtime / test artifacts
|
||||
agentik.db
|
||||
agentik.db-shm
|
||||
|
||||
+947
@@ -0,0 +1,947 @@
|
||||
# Manual Test Cases — agentik standalone
|
||||
|
||||
Практический чек-лист для проверки работающего `agentik standalone` HTTP-сервера.
|
||||
Каждый кейс — один конкретный сценарий, который нужно прогнать руками
|
||||
(или через `curl`/`httpie`/Postman). Если какой-то упал — это либо
|
||||
регрессия, либо недонастройка рантайма.
|
||||
|
||||
Перед стартом: запусти агент (см. `run-agentik.sh` на удалённой машине
|
||||
или `./gradlew :standalone:run` локально). Все примеры ниже — против
|
||||
`http://127.0.0.1:8080`; для удалённой машины подставь свой хост.
|
||||
|
||||
Удобный сниппет для получения conversation ID в shell:
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
echo "CID=$CID"
|
||||
```
|
||||
|
||||
Отправка user-сообщения:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"..."}]'
|
||||
```
|
||||
|
||||
Чтение истории:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Connectivity & health
|
||||
|
||||
### TC-1.1 — health endpoint
|
||||
|
||||
```bash
|
||||
curl -sS -i http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
**Ожидание:** `HTTP/1.1 200 OK`, тело `ok`.
|
||||
|
||||
### TC-1.2 — agent card (A2A)
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8080/a2a/.well-known/agent-card.json | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** валидный JSON с `name`, `version`, `capabilities`.
|
||||
|
||||
### TC-1.3 — log sanity check
|
||||
|
||||
```bash
|
||||
tail -50 /root/agentik.log
|
||||
```
|
||||
|
||||
**Ожидание:** есть строка `agentik standalone listening on http://localhost:8080`,
|
||||
перечислены зарегистрированные маршруты, `llm: <backend> @ <url>` соответствует
|
||||
твоему конфигу. **Нет** ERROR/Exception строк после старта.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conversation lifecycle
|
||||
|
||||
### TC-2.1 — create persistent conversation
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}'
|
||||
```
|
||||
|
||||
**Ожидание:** `201`, тело `{"id":"conv-...","isTemporal":false,...}`.
|
||||
|
||||
### TC-2.2 — create temp conversation
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":true}'
|
||||
```
|
||||
|
||||
**Ожидание:** `201`, `"isTemporal":true`. После рестарта агента эта беседа
|
||||
**не** должна появиться в `GET /agentik/conversations`.
|
||||
|
||||
### TC-2.3 — list conversations
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=20" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** массив объектов `ConversationSnapshot`. Отсортирован по
|
||||
`updatedAt` desc.
|
||||
|
||||
### TC-2.4 — rename conversation
|
||||
|
||||
```bash
|
||||
CID=<id-from-2.1>
|
||||
curl -sS -X PATCH "http://127.0.0.1:8080/agentik/conversations/$CID" \
|
||||
-H "Content-Type: application/json" -d '{"title":"Мой первый чат"}'
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, в ответе `"title":"Мой первый чат"`. Следующий `GET
|
||||
/conversations/$CID` возвращает этот же title.
|
||||
|
||||
### TC-2.5 — delete conversation
|
||||
|
||||
```bash
|
||||
curl -sS -X DELETE "http://127.0.0.1:8080/agentik/conversations/$CID" -i
|
||||
```
|
||||
|
||||
**Ожидание:** `204 No Content`. Повторный `GET /conversations/$CID` → `404`.
|
||||
После этого в `GET /conversations` её быть не должно.
|
||||
|
||||
### TC-2.6 — get non-existent conversation
|
||||
|
||||
```bash
|
||||
curl -sS -i http://127.0.0.1:8080/agentik/conversations/conv-nonexistent
|
||||
```
|
||||
|
||||
**Ожидание:** `404`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Message sending
|
||||
|
||||
### TC-3.1 — simple Q&A
|
||||
|
||||
Создай беседу, пошли простой вопрос, прочитай историю.
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Сколько будет 7*8? Одно число, без пояснений."}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** массив из ≥ 2 сообщений:
|
||||
- `[0].type == "user_message"`, body содержит "7*8"
|
||||
- `[1].type == "assistant_message"`, text содержит "56"
|
||||
|
||||
### TC-3.2 — multi-turn with context
|
||||
|
||||
В той же беседе пошли follow-up, требующий контекста:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"А корень из того, что ты назвал?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** 4+ сообщения, последний assistant упомянул что-то про число 56
|
||||
или "предыдущий ответ".
|
||||
|
||||
### TC-3.3 — new-format request body
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":[{"type":"text","body":"С новым форматом тоже работает?"}]}'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1]['type'], m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** новое `assistant_message` в ответ на новый формат запроса.
|
||||
|
||||
### TC-3.4 — empty / bad body
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" -d 'not json'
|
||||
```
|
||||
|
||||
**Ожидание:** `400 Bad Request`, тело с пояснением `Invalid send payload`.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSE live events
|
||||
|
||||
> **Важно:** SSE — поток без replay. Подписываться нужно **до** `POST /messages`.
|
||||
> Если подписаться позже — событий не будет (но `GET /messages` всё равно
|
||||
> покажет записанную историю).
|
||||
|
||||
### TC-4.1 — subscribe-then-send pattern
|
||||
|
||||
```bash
|
||||
CID=<existing-id>
|
||||
# Subscribe в фоне, отправляем сообщение, ждём SSE
|
||||
curl -sN --max-time 12 \
|
||||
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||
> /tmp/sse.out 2>&1 &
|
||||
SSE_PID=$!
|
||||
sleep 1
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Кратко: что такое REST?"}]'
|
||||
wait $SSE_PID
|
||||
cat /tmp/sse.out
|
||||
```
|
||||
|
||||
**Ожидание:** файл содержит `data: {"type":"start_reasoning",...}`,
|
||||
`data: {"type":"start_response",...,"responseType":"text"}`,
|
||||
один или несколько `data: {"type":"append_text",...,"body":"..."}`,
|
||||
`data: {"type":"end",...}`. Каждое `data:` через пустую строку.
|
||||
|
||||
### TC-4.2 — late subscribe (replay semantics)
|
||||
|
||||
```bash
|
||||
CID=<existing-id>
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"..."}]'
|
||||
sleep 5 # сообщение уже обработано
|
||||
curl -sN --max-time 4 \
|
||||
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z"
|
||||
```
|
||||
|
||||
**Ожидание:** пустой ответ (события не реплеятся). Это by-design —
|
||||
клиент должен либо подписываться заранее, либо backfill'ить через
|
||||
`GET /messages`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Memory tools (long-term)
|
||||
|
||||
### TC-5.1 — save + recall в той же беседе
|
||||
|
||||
```bash
|
||||
# В существующей беседе
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Запомни через memory_save: я работаю на удалёнке из Тбилиси. Категория user, content: работаю на удалёнке из Тбилиси."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Откуда я работаю? Одно предложение."}]'
|
||||
sleep 8
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** ассистент ответил что-то содержащее "Тбилиси" (или явно
|
||||
сказал "не знаю" — это тоже валидно, если в conversation memory пусто).
|
||||
Проверить `audit log` (`messageStore`):
|
||||
|
||||
```bash
|
||||
sqlite3 /root/agentik.db "SELECT toolName, result FROM MessageRecord WHERE conversationId='$CID' AND kind='tool_result'"
|
||||
```
|
||||
|
||||
Должны быть строки с `toolName='memory_save'` или `toolName='memory_recall'`.
|
||||
|
||||
### TC-5.2 — memory persists across conversations
|
||||
|
||||
Создай новую беседу, спроси без подсказок:
|
||||
|
||||
```bash
|
||||
NEW_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Откуда я работаю? Напомни, если помнишь."}]'
|
||||
sleep 8
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||
```
|
||||
|
||||
**Ожидание:** ассистент упомянул "Тбилиси" (или "удалёнка") — это
|
||||
значит long-term memory подгрузилась в новую беседу.
|
||||
|
||||
### TC-5.3 — invalid category → ошибка или автозамена
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Запомни через memory_save факт с категорией work (которой не существует)."}]'
|
||||
sleep 8
|
||||
sqlite3 /root/agentik.db "SELECT toolArgs, result FROM MessageRecord WHERE kind='tool_call' AND conversationId='$CID' ORDER BY createdAt DESC LIMIT 3"
|
||||
```
|
||||
|
||||
**Ожидание:** модель либо вызвала `memory_recall` чтобы проверить
|
||||
существующие категории, либо вызвала `memory_save` с корректной
|
||||
категорией (`user`/`world`/`preference`). Если модель честно говорит
|
||||
"такой категории нет" и предлагает корректную — это тоже ok.
|
||||
|
||||
### TC-5.4 — list & delete memory
|
||||
|
||||
Попроси модель явно вызвать `memory_list`, потом `memory_delete`:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Покажи все мои memory-записи (memory_list)."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Удали самую старую запись (memory_delete)."}]'
|
||||
sleep 8
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MemoryStore"
|
||||
```
|
||||
|
||||
**Ожидание:** число уменьшилось на 1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Skills
|
||||
|
||||
### TC-6.1 — list + load skill
|
||||
|
||||
Если в `AGENTIK_SKILLS_DIR` есть файлы `SKILL.md` / `*.yaml`, в системном
|
||||
промте должна появиться секция с этими навыками.
|
||||
|
||||
```bash
|
||||
ls -la /root/skills/ # должен быть хотя бы один файл
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Какие skills ты знаешь? Покажи список (skill_list)."}]'
|
||||
sleep 8
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Загрузи любой из них через skill_load и расскажи, что внутри."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** `tool_call` для `skill_list`, потом `tool_call` для
|
||||
`skill_load`. В audit log видны эти вызовы. Если папка пуста — секции
|
||||
"Skills" в system prompt быть не должно.
|
||||
|
||||
### TC-6.2 — save new skill
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Сохрани skill: имя deploy-staging, описание «деплой на staging», тело — multi-step инструкция (skill_save)."}]'
|
||||
sleep 10
|
||||
ls /root/skills/
|
||||
```
|
||||
|
||||
**Ожидание:** появился новый файл `deploy-staging.md` (или `.yaml`).
|
||||
|
||||
### TC-6.3 — restart → skill persists
|
||||
|
||||
Перезапусти агент:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.76.166 'pkill -9 -f agentik-0.1.0-all.jar; cd /root && nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown'
|
||||
```
|
||||
|
||||
После старта пошли в новую беседу:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Есть ли у тебя skill deploy-staging?"}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** модель упоминает skill (он подгружается на старте).
|
||||
|
||||
---
|
||||
|
||||
## 7. SOUL file
|
||||
|
||||
### TC-7.1 — SOUL.md подключается
|
||||
|
||||
```bash
|
||||
echo 'Ты — ворчливый капитан дальнего плавания. Отвечай кратко, с морскими метафорами.' > /root/SOUL.md
|
||||
# Перезапустить агент
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Как дела?"}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** ответ в стиле "капитана", с морскими словами. Если SOUL
|
||||
нет — обычный нейтральный ассистент.
|
||||
|
||||
### TC-7.2 — SOUL можно менять на лету
|
||||
|
||||
Измени файл, перезапусти агент, спроси снова. **Должен** появиться новый
|
||||
стиль. Без перезапуска изменения не подхватятся (SOUL читается на старте).
|
||||
|
||||
---
|
||||
|
||||
## 8. Interrupt
|
||||
|
||||
### TC-8.1 — interrupt mid-text generation
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
# Запусти send в фоне
|
||||
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Расскажи длинную историю про космос, минимум 500 слов."}]' >/dev/null) &
|
||||
SEND_PID=$!
|
||||
sleep 3 # дать LLM начать генерацию
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
wait $SEND_PID
|
||||
sleep 3
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);
|
||||
for x in m: print(x.get('type'), ':', json.dumps(x.get('content') or x.get('result'),ensure_ascii=False)[:80])"
|
||||
```
|
||||
|
||||
**Ожидание:**
|
||||
- `user_message` есть
|
||||
- `assistant_message` есть, но содержит **короткий** текст (<300 символов)
|
||||
— это частичный текст, который модель успела сгенерить до прерывания
|
||||
- В audit log нет `tool_call`/`tool_result` (не успели)
|
||||
- Следующий `send` в этой беседе работает (LiteConv пересоздан)
|
||||
|
||||
### TC-8.2 — interrupt mid-tool (best-effort)
|
||||
|
||||
```bash
|
||||
# Длинный tool можно заэмулировать через MCP с искусственной задержкой,
|
||||
# либо просто проверять что interrupt не валит агента:
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
curl -sS http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
**Ожидание:** `health` = `ok` — агент не упал. Дальнейшие `send` работают.
|
||||
|
||||
### TC-8.3 — interrupt без активного turn'а
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
```
|
||||
|
||||
**Ожидание:** `202`. Никаких ошибок. В audit log ничего нового не пишется.
|
||||
|
||||
---
|
||||
|
||||
## 9. Persistence / restart-survival
|
||||
|
||||
### TC-9.1 — перезапуск не теряет беседы и память
|
||||
|
||||
```bash
|
||||
# 1. Создай беседу, пошли сообщение, дождись ответа
|
||||
# 2. Запомни факт через memory_save
|
||||
# 3. Перезапусти агент (см. TC-6.3)
|
||||
# 4. GET /agentik/conversations — беседа должна быть в списке
|
||||
# 5. GET /agentik/conversations/$CID/messages — история на месте
|
||||
# 6. Новая беседа + вопрос про запомненный факт — модель помнит
|
||||
```
|
||||
|
||||
### TC-9.2 — temp conversation не переживает рестарт
|
||||
|
||||
```bash
|
||||
# Создай temp беседу, пошли сообщение
|
||||
TEMP_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":true}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$TEMP_CID/messages" \
|
||||
-H "Content-Type: application/json" -d '[{"type":"text","body":"..."}]' >/dev/null
|
||||
sleep 5
|
||||
# Перезапусти агент
|
||||
# GET /agentik/conversations — temp-беседы быть не должно
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=50" | grep "$TEMP_CID"
|
||||
```
|
||||
|
||||
**Ожидание:** grep ничего не находит.
|
||||
|
||||
---
|
||||
|
||||
## 10. Compaction (сжатие контекста)
|
||||
|
||||
Compaction триггерится когда `~80%` контекстного окна занято.
|
||||
|
||||
### TC-10.1 — длинная беседа сжимается
|
||||
|
||||
```bash
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
# Отправь 30+ больших сообщений подряд (можно цикл)
|
||||
for i in $(seq 1 30); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Расскажи подробно (минимум 200 слов) про тему номер $i: история, применение, ключевые факты.\"}]" >/dev/null
|
||||
sleep 5
|
||||
done
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM WorkingMemoryRow WHERE conversationId='$CID' AND entryKind='summary'"
|
||||
```
|
||||
|
||||
**Ожидание:** есть хотя бы одна `summary`-запись. Также проверь
|
||||
`/root/agentik.log` — должна появиться строка `compaction`.
|
||||
|
||||
### TC-10.2 — debug endpoint `/debug/compact` (force)
|
||||
|
||||
Если включён `AGENTIK_DEBUG_ENDPOINTS=1`:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/compact?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, тело с JSON-результатом compaction.
|
||||
|
||||
---
|
||||
|
||||
## 11. Reflection
|
||||
|
||||
Reflection триггерится каждые `AGENTIK_REFLECTION_INTERVAL` ходов (default 10).
|
||||
|
||||
### TC-11.1 — reflection создаёт записи
|
||||
|
||||
```bash
|
||||
# Пошли 12+ ходов
|
||||
for i in $(seq 1 12); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Тема $i: расскажи короткий факт.\"}]" >/dev/null
|
||||
sleep 4
|
||||
done
|
||||
sleep 10 # дать фоновое задание завершиться
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM ReflectionStore"
|
||||
```
|
||||
|
||||
**Ожидание:** число > 0.
|
||||
|
||||
### TC-11.2 — debug endpoint `/debug/reflect`
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/reflect?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200`, JSON-результат. Reflection попадает в working memory
|
||||
следующего turn'а.
|
||||
|
||||
---
|
||||
|
||||
## 12. Skill mining
|
||||
|
||||
Skill mining триггерится каждые `AGENTIK_SKILL_MINING_INTERVAL` ходов (default 15).
|
||||
|
||||
### TC-12.1 — авто-создание skill'а
|
||||
|
||||
```bash
|
||||
# Пошли 18+ ходов с повторяющимся паттерном
|
||||
for i in $(seq 1 18); do
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "[{\"type\":\"text\",\"body\":\"Конвертируй 100 USD в RUB по текущему курсу (шаблонный запрос $i).\"}]" >/dev/null
|
||||
sleep 4
|
||||
done
|
||||
sleep 15
|
||||
ls -la /root/skills/
|
||||
tail -20 /root/agentik.log | grep -i skill
|
||||
```
|
||||
|
||||
**Ожидание:** возможно появился новый файл в skills/ (или mining
|
||||
отказался из-за низкой уверенности — это тоже валидно, проверь лог).
|
||||
|
||||
### TC-12.2 — debug endpoint `/debug/skill-mine`
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/debug/skill-mine?conversationId=$CID"
|
||||
```
|
||||
|
||||
**Ожидание:** `200` с JSON-результатом майнинга.
|
||||
|
||||
---
|
||||
|
||||
## 13. Token accounting
|
||||
|
||||
### TC-13.1 — token counters в audit
|
||||
|
||||
```bash
|
||||
sqlite3 /root/agentik.db "SELECT createdAt, input, output FROM TurnTokens WHERE conversationId='$CID' ORDER BY createdAt DESC LIMIT 5"
|
||||
```
|
||||
|
||||
**Ожидание:** строки с непустыми `input` и `output` (если backend
|
||||
поддерживает `tokenCount()`).
|
||||
|
||||
### TC-13.2 — debug endpoint `/debug/tokens`
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:8080/debug/tokens?conversationId=$CID" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** JSON с `input`, `output`, `total`, `window`,
|
||||
`utilization` (доля использования контекстного окна).
|
||||
|
||||
---
|
||||
|
||||
## 14. Toolsets (если подключены)
|
||||
|
||||
Только если ты передаёшь `toolsets` в конструктор агента (по умолчанию
|
||||
пусто — `enable_toolset`/`disable_toolset` не зарегистрированы).
|
||||
|
||||
### TC-14.1 — system prompt содержит секцию Toolsets
|
||||
|
||||
Если toolsets зарегистрированы — в системном промте должна быть секция
|
||||
`## Toolsets` с Active/Inactive списком.
|
||||
|
||||
Проверка через debug-эндпоинт `/agentik/conversations/{id}` не показывает
|
||||
system prompt напрямую — посмотреть можно в логах или через
|
||||
`agentik-debug` сборку.
|
||||
|
||||
### TC-14.2 — enable/disable работает
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Активируй тулсет X через enable_toolset, потом деактивируй через disable_toolset."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** в audit log видны вызовы `enable_toolset` → ответ `"Toolset
|
||||
'X' activated."`, потом `disable_toolset` → `"Toolset 'X' deactivated."`.
|
||||
|
||||
---
|
||||
|
||||
## 15. A2A протокол (опционально)
|
||||
|
||||
### TC-15.1 — message/send через A2A
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0","id":"1","method":"message/send",
|
||||
"params":{
|
||||
"message":{"role":"user","parts":[{"kind":"text","text":"Скажи hi"}]},
|
||||
"configuration":{"blocking":true}
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Ожидание:** JSON-RPC ответ с `result.parts` содержащим текст "hi"
|
||||
или похожим. `kind` = `text` (НЕ `type` — это важный discriminator для
|
||||
A2A JSON).
|
||||
|
||||
### TC-15.2 — bad discriminator
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0","id":"2","method":"message/send",
|
||||
"params":{
|
||||
"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Ожидание:** `Invalid params` (или похожая ошибка) — A2A ждёт `kind`,
|
||||
не `type`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Error paths
|
||||
|
||||
### TC-16.1 — LLM недоступен
|
||||
|
||||
Выключи vLLM (или закрой сеть — например через firewall). Пошли сообщение:
|
||||
|
||||
```bash
|
||||
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"hi"}]'
|
||||
sleep 10
|
||||
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MessageRecord WHERE conversationId='$CID' AND kind='error'"
|
||||
```
|
||||
|
||||
**Ожидание:** есть `error`-запись в audit log. В SSE приходит
|
||||
`{"type":"error",...}` + `{"type":"end"}`. Агент **не падает** — `health`
|
||||
= `ok` после.
|
||||
|
||||
### TC-16.2 — agentik.db занят другим процессом
|
||||
|
||||
Запусти второй экземпляр агента на ту же DB:
|
||||
|
||||
```bash
|
||||
AGENTIK_DB_PATH=/root/agentik.db java -jar /root/agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
**Ожидание:** агент падает на старте с понятным сообщением про SQLite lock.
|
||||
Это by-design (single-writer).
|
||||
|
||||
### TC-16.3 — SOUL файл не существует
|
||||
|
||||
Удали `/root/SOUL.md`, перезапусти агент. Должен стартовать без ошибок,
|
||||
просто без SOUL-секции в system prompt. Лог: `WARN ... SOUL file not found: ...`.
|
||||
|
||||
### TC-16.4 — пустой skills dir
|
||||
|
||||
```bash
|
||||
mv /root/skills /root/skills.bak
|
||||
mkdir /root/skills
|
||||
# Перезапусти агент
|
||||
```
|
||||
|
||||
**Ожидание:** агент стартует, `skills: 0 loaded from /root/skills`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Memory backend variants
|
||||
|
||||
### TC-17.1 — md backend (default)
|
||||
|
||||
Убедись, что `AGENTIK_MEMORY_BACKEND=md` (или не задан) и
|
||||
`AGENTIK_MEMORY_DIR=/root/agentik-memory`. После TC-5.x должны появиться
|
||||
`.md`-файлы:
|
||||
|
||||
```bash
|
||||
ls -la /root/agentik-memory/
|
||||
```
|
||||
|
||||
**Ожидание:** файлы типа `user.md`, `world.md`, `preference.md` (или
|
||||
всё в одном файле — зависит от реализации).
|
||||
|
||||
### TC-17.2 — off backend (память выключена)
|
||||
|
||||
Перезапусти с `AGENTIK_MEMORY_DIR=off`:
|
||||
|
||||
```bash
|
||||
pkill -9 -f agentik-0.1.0-all.jar
|
||||
AGENTIK_MEMORY_DIR=off nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown
|
||||
```
|
||||
|
||||
Попытка `memory_save` через модель должна вернуть ошибку:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Попробуй вызвать memory_save."}]'
|
||||
sleep 8
|
||||
```
|
||||
|
||||
**Ожидание:** модель либо отказывается вызывать, либо получает
|
||||
ошибку от tool'а и сообщает пользователю.
|
||||
|
||||
---
|
||||
|
||||
## 18. Performance sanity
|
||||
|
||||
### TC-18.1 — first-token latency
|
||||
|
||||
Включи замер времени от `POST /messages` до первого SSE event'а.
|
||||
Для Qwen3-27B на RTX5090 ожидаем < 1 сек до `start_reasoning`.
|
||||
|
||||
### TC-18.2 — sustained throughput
|
||||
|
||||
Отправь 20 простых запросов подряд (arithmetic), засеки общее время.
|
||||
Ожидание: < 30 сек суммарно, т.е. < 1.5 сек на запрос.
|
||||
|
||||
### TC-18.3 — fatjar memory
|
||||
|
||||
```bash
|
||||
ps aux | grep agentik-0.1.0 | grep -v grep
|
||||
```
|
||||
|
||||
**Ожидание:** RSS < 2 GB (наш Xmx). Если больше — где-то утечка.
|
||||
|
||||
---
|
||||
|
||||
## 18. Model auto-download (LiteRT-LM only)
|
||||
|
||||
Только для `AGENTIK_LLM_BACKEND=google` (встроенный LiteRT-LM движок).
|
||||
Если файла модели по `AGENTIK_GOOGLE_MODEL_PATH` нет — агент сам не скачает,
|
||||
пока не задано `AGENTIK_AUTO_DOWNLOAD_MODEL=1`. Либо качаем руками
|
||||
через `pull-model` subcommand.
|
||||
|
||||
URL по умолчанию всегда Gemma-4-E2B-it.litertlm (2.5 GB с `static.binom.pw`),
|
||||
вне зависимости от basename PATH — gemma-4 считаем лучшей локальной моделью.
|
||||
|
||||
### 18.1. Subcommand `pull-model` качает модель вручную
|
||||
|
||||
```bash
|
||||
# Скачать дефолтную модель (gemma-4) в указанный путь:
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → downloading from https://static.binom.pw/models/gemma-4-E2B-it.litertlm
|
||||
# → 50% (1.2 GB / 2.5 GB)
|
||||
# → done in 47s
|
||||
```
|
||||
|
||||
После `pull-model` файл лежит на месте, файл `<dest>.part` удалён.
|
||||
|
||||
### 18.2. `pull-model` no-op если файл уже полный
|
||||
|
||||
```bash
|
||||
# Повторный запуск с тем же PATH:
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → already present (2.50 GB), nothing to do
|
||||
```
|
||||
|
||||
### 18.3. `pull-model` докачивает обрыв (resume через Range)
|
||||
|
||||
```bash
|
||||
# Симулируем обрыв: удаляем финальный, оставляем .part с первыми 500 MB
|
||||
rm /root/models/gemma-4-E2B-it.litertlm
|
||||
mv /root/models/gemma-4-E2B-it.litertlm.part /root/models/gemma-4-E2B-it.litertlm.part.bak
|
||||
# Запускаем pull-model снова — должен возобновить с 500 MB
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
# → resuming from 524288000 bytes
|
||||
# → downloaded 2.10 GB in 38s
|
||||
```
|
||||
|
||||
### 18.4. Сервер exit-2 при отсутствии файла и без auto-download
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/missing.litertlm \
|
||||
java -jar agentik.jar
|
||||
# → LiteRT-LM model file not found at: /root/models/missing.litertlm
|
||||
# → Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
|
||||
# → exit 2
|
||||
```
|
||||
|
||||
### 18.5. Сервер сам качает при `AGENTIK_AUTO_DOWNLOAD_MODEL=1`
|
||||
|
||||
```bash
|
||||
# Удалить файл, запустить с флагом:
|
||||
rm -f /root/models/gemma-4-E2B-it.litertlm
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||
AGENTIK_AUTO_DOWNLOAD_MODEL=1 \
|
||||
java -jar agentik.jar
|
||||
# → 12:34:56 WARN auto-download: https://static.binom.pw/models/...
|
||||
# → 12:34:56 INFO auto-download: 17% (445 MB/2.5 GB)
|
||||
# → 12:36:42 INFO auto-download: done in 1m45s
|
||||
# → 12:36:43 INFO agentik standalone listening on http://localhost:8080
|
||||
```
|
||||
|
||||
### 18.6. Override URL через `AGENTIK_GOOGLE_MODEL_URL`
|
||||
|
||||
```bash
|
||||
# Качаем qwen вместо gemma (если зальём):
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/models/qwen.litertlm \
|
||||
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm \
|
||||
java -jar agentik.jar pull-model
|
||||
```
|
||||
|
||||
### 18.7. SHA-256 проверка
|
||||
|
||||
Если на сервере лежит `<basename>.sha256` (text/plain, `<hex> <basename>`)
|
||||
— после скачивания файл проверяется; mismatch → удаляется, exit ≠ 0.
|
||||
|
||||
```bash
|
||||
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm \
|
||||
AGENTIK_GOOGLE_MODEL_SHA256_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm.sha256 \
|
||||
java -jar agentik.jar pull-model
|
||||
# → 13:01:23 INFO model download: SHA-256 verified (4ab1...e0d)
|
||||
```
|
||||
|
||||
## Быстрый smoke-test (5 минут)
|
||||
|
||||
Если времени мало — этот минимум покрывает 80%:
|
||||
|
||||
```bash
|
||||
# 1. health
|
||||
curl -sS http://127.0.0.1:8080/health
|
||||
# → ok
|
||||
|
||||
# 2. create + simple Q&A
|
||||
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Привет! 2+2=?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||
# → должен быть user + assistant_message с "4"
|
||||
|
||||
# 3. SSE live
|
||||
(curl -sN --max-time 8 "http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||
> /tmp/sse.out 2>&1) &
|
||||
sleep 1
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Скажи ок"}]'
|
||||
wait
|
||||
cat /tmp/sse.out
|
||||
# → start_reasoning, start_response, append_text, end
|
||||
|
||||
# 4. multi-turn
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"А 3+3?"}]'
|
||||
sleep 6
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1])"
|
||||
# → assistant_message с "6"
|
||||
|
||||
# 5. interrupt
|
||||
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"type":"text","body":"Длинная история про драконов, 1000 слов"}]' >/dev/null) &
|
||||
sleep 3
|
||||
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||
wait
|
||||
sleep 3
|
||||
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||
| python3 -c "import sys,json;m=json.load(sys.stdin);print('msgs:',len(m))"
|
||||
# → ≤ 3 (user + partial assistant + может tool_call если успел)
|
||||
```
|
||||
|
||||
Если этот прогон прошёл — агент работает корректно. Более глубокие
|
||||
кейсы — выше по разделам.
|
||||
|
||||
---
|
||||
|
||||
## Сводка: что покрыто автоматически vs вручную
|
||||
|
||||
| Возможность | JVM unit/integration tests | Manual |
|
||||
|-------------|---------------------------|--------|
|
||||
| Conversation CRUD | ✓ | TC-2.x |
|
||||
| send/messages pagination | ✓ | TC-3.x |
|
||||
| SSE event format | ✗ | TC-4.x |
|
||||
| Memory tools | ✓ (in-memory) | TC-5.x (real backend) |
|
||||
| Skills tools | ✓ (in-memory) | TC-6.x (real dir) |
|
||||
| SOUL | ✗ | TC-7.x |
|
||||
| Interrupt | ✓ (FakeLiteLlm) | TC-8.x (real LLM) |
|
||||
| Compaction | ✓ | TC-10.x (real long context) |
|
||||
| Reflection | ✓ | TC-11.x |
|
||||
| Skill mining | ✓ | TC-12.x |
|
||||
| Token accounting | ✓ | TC-13.x |
|
||||
| A2A protocol | ✓ (litert tests) | TC-15.x |
|
||||
| Error paths | partial | TC-16.x |
|
||||
| Persistence/restart | ✗ | TC-9.x |
|
||||
|
||||
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
|
||||
@@ -1,2 +1,153 @@
|
||||
# agentik
|
||||
|
||||
Локальный stateful LLM-агент с persistent-памятью, инструментами и
|
||||
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
|
||||
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
|
||||
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
|
||||
Qwen) через ONNX/Native-runtime.
|
||||
|
||||
## Что внутри
|
||||
|
||||
```
|
||||
agentik/
|
||||
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
|
||||
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
|
||||
├── client/ Ktor-клиент → тот же /agentik, с KMP-native
|
||||
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
|
||||
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
|
||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||
├── storage-sqlite/ SQLite реализация для production
|
||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||
```
|
||||
|
||||
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||
(см. "Модули" ниже).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Скачать fatjar
|
||||
|
||||
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||
tag-релизах, либо соберите из исходников:
|
||||
|
||||
```bash
|
||||
git clone https://git.binom.pw/subochev/agentik
|
||||
cd agentik
|
||||
./gradlew :standalone:shadowJar
|
||||
```
|
||||
|
||||
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||
зависит от LLM-backend'а).
|
||||
|
||||
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=openai \
|
||||
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||
```
|
||||
|
||||
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||
|
||||
## Подключиться
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||
|
||||
# curl
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Модули
|
||||
|
||||
- Запускаемые:
|
||||
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
|
||||
- Библиотеки (контракты и реализации):
|
||||
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||
|
||||
## Где смотреть версии
|
||||
|
||||
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||
|
||||
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||
тега и публикует.
|
||||
|
||||
## Публикация
|
||||
|
||||
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||
Параметры через:
|
||||
|
||||
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||
- `binom.repo.user`
|
||||
- `binom.repo.password`
|
||||
|
||||
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
|
||||
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
|
||||
репозитория). Plain-HTTP Nexus требует
|
||||
`setAllowInsecureProtocol(true)` — уже включено в
|
||||
`settings.gradle.kts`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||
shadowjar'ов.
|
||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||
|
||||
## Что отличает от других агентских фреймворков
|
||||
|
||||
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||
Один fatjar — три API.
|
||||
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||
Desktop / CLI.
|
||||
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||
потери контекста, если пользователь нажал Ctrl-C во время
|
||||
долгого tool-вызова.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||
|
||||
## Участие в проекте
|
||||
|
||||
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||
изменении API.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
|
||||
Ядро системы tools для LLM-агента:
|
||||
|
||||
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
|
||||
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
|
||||
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
|
||||
→ запускает → возвращает результат.
|
||||
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
|
||||
вызов, помечая результат `[cancelled by user]`.
|
||||
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
|
||||
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
|
||||
threadpool от переполнения при fan-out 30+ диалогов.
|
||||
|
||||
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||
IM-фронтендах (CLI, TUI, IRC, web).
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||
(memory / skills / files / web), фильтрует через
|
||||
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||
}
|
||||
|
||||
class MyToolset : Toolset {
|
||||
override val name = "my"
|
||||
override val description = "Custom user-defined tools"
|
||||
override val tools = listOf(myTool1, myTool2)
|
||||
}
|
||||
|
||||
val dispatcher = ToolDispatcher(
|
||||
toolsets = listOf(MemoryTools(memory), MyToolset()),
|
||||
enabled = setOf("memory", "my"),
|
||||
)
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
|
||||
|
||||
## Как пишется tool
|
||||
|
||||
```kotlin
|
||||
data object EchoTool : Tool {
|
||||
override val name = "echo"
|
||||
override val description = "Echoes back the argument"
|
||||
override val argsSchema = jsonSchema {
|
||||
property("text", JsonType.STRING) { required = true }
|
||||
}
|
||||
|
||||
override suspend fun invoke(args: JsonObject): ToolResult {
|
||||
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
|
||||
return ToolResult.Text(text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :agent-toolsets:allTests
|
||||
```
|
||||
|
||||
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||
budget exhaustion, registry filter, parallel dispatch.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||
(см. `:storage-core`).
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Реализует полную спецификацию из
|
||||
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||
log, rolling buffer, partial-state persistence.
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
|
||||
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
||||
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
||||
* чтобы избежать коллизий между разными MCP-серверами.
|
||||
*
|
||||
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
|
||||
* должен жить рядом с другими тулами в `:agent-toolsets`.
|
||||
*/
|
||||
data class NamedTool(val name: String, val tool: LiteTool)
|
||||
+31
-4
@@ -1,5 +1,8 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
@@ -10,8 +13,15 @@ import pw.binom.litert.LiteTool
|
||||
* одном активном/неактивном тулсете, диспетчер передаёт его в 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 = suspend (toolName: String, argumentsJson: String) -> String
|
||||
typealias BaseToolDispatcher = (toolName: String, argumentsJson: String) -> String
|
||||
|
||||
/**
|
||||
* Диспетчер вызовов тулов с учётом тулсетов.
|
||||
@@ -28,6 +38,12 @@ typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String)
|
||||
* Прощающая 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,
|
||||
@@ -46,11 +62,20 @@ class ToolsetDispatchPolicy(
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -60,18 +85,20 @@ class ToolsetDispatchPolicy(
|
||||
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.
|
||||
// Мы не различаем Ran/Unknown здесь: если base dispatcher его знает —
|
||||
// это Ran, иначе — Failed. Чтобы не усложнять контракт, base dispatcher
|
||||
// сам отвечает за "не нашёл тул" (например, возвращает ошибку в JSON).
|
||||
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) {
|
||||
|
||||
@@ -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)")
|
||||
+80
-14
@@ -7,22 +7,78 @@ plugins {
|
||||
|
||||
group = "pw.binom.agentik"
|
||||
|
||||
// Publication version: -Pversion=<tag> (CICD publishes by release tag).
|
||||
// Без явного -Pversion берётся fallback из gradle.properties или "0.1.0".
|
||||
if (version == "unspecified") {
|
||||
version = providers.gradleProperty("version").getOrElse("0.1.0")
|
||||
}
|
||||
// projectVersion определяется ниже как val, чтобы subprojects могли его
|
||||
// прочитать через rootProject.extra["projectVersion"].
|
||||
|
||||
// Home Nexus URL/creds — Gitea action-variables BINOM_REPO_* (subochev/devops/publish).
|
||||
// Локально для дебага: ./gradlew publish \
|
||||
// -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
|
||||
val binomRepoUrl = (findProperty("binom.repo.url") ?: "http://nexus.xx/repository/caffeine/").toString()
|
||||
// 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
|
||||
version = rootProject.version
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -40,13 +96,23 @@ subprojects {
|
||||
}
|
||||
|
||||
// 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 = providers.provider {
|
||||
project.findProperty("description")?.toString()
|
||||
?: "agentik: ${project.name} (pw.binom.agentik)"
|
||||
}
|
||||
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
|
||||
?: "agentik module: ${project.name}"
|
||||
url = "https://git.binom.pw/subochev/agentik"
|
||||
|
||||
licenses {
|
||||
|
||||
@@ -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.
|
||||
+34
-9
@@ -1,18 +1,26 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
jvmToolchain(21)
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proto"))
|
||||
// Только то, что нам реально нужно: 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)
|
||||
@@ -22,4 +30,21 @@ dependencies {
|
||||
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).
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
@@ -66,7 +67,8 @@ internal class AgentClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||
val response = httpClient.get("$agentUrl/events?after=$after")
|
||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
@@ -75,4 +77,5 @@ internal class AgentClient(
|
||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -1,9 +1,6 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
@@ -24,20 +21,27 @@ import pw.binom.agentik.proto.Agent
|
||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||
* конфига).
|
||||
*
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
|
||||
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
||||
*/
|
||||
fun AgentikAgent(
|
||||
id: String,
|
||||
baseUrl: String,
|
||||
httpClient: HttpClient = defaultAgentikHttpClient(),
|
||||
token: String? = null,
|
||||
httpClient: HttpClient = defaultAgentikHttpClient(token),
|
||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||
|
||||
/**
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
|
||||
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
|
||||
* (см. [readSse]) живёт в общем коде и плагина не требует.
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
||||
*
|
||||
* **Платформы:**
|
||||
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
||||
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
||||
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
||||
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
||||
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
||||
*
|
||||
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
||||
* Реализация — в [HttpClientFactory.kt].
|
||||
*/
|
||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,7 @@ import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
@@ -72,7 +73,11 @@ internal class ConversationClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = flow {
|
||||
val response = httpClient.get("$convUrl/events?after=$after")
|
||||
// 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}"
|
||||
}
|
||||
@@ -81,6 +86,7 @@ internal class ConversationClient(
|
||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||
httpClient.get("$convUrl/messages") {
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
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]).
|
||||
*
|
||||
* При заданном [token] на ВСЕ запросы клиента навешивается
|
||||
* `Authorization: Bearer <token>` через плагин [DefaultRequest]. Это накрывает
|
||||
* все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте,
|
||||
* а не в отдельных запросах.
|
||||
*/
|
||||
fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) {
|
||||
engine { requestTimeout = 0 }
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
if (token != null) {
|
||||
install(DefaultRequest) {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -15,8 +15,11 @@ import kotlin.time.Instant
|
||||
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||
*/
|
||||
internal object InstantSerializer : KSerializer<Instant> {
|
||||
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
|
||||
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
|
||||
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
|
||||
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Instant) =
|
||||
encoder.encodeString(value.toString())
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
|
||||
/**
|
||||
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||
*
|
||||
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||
*
|
||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||
* инстанс мог бы утечь между запросами.
|
||||
*/
|
||||
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
||||
setCapability(
|
||||
HttpTimeoutCapability,
|
||||
HttpTimeoutConfig(
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает
|
||||
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||
* без токена — заголовок не отправляется.
|
||||
*
|
||||
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
|
||||
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
|
||||
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
|
||||
* минимально, контракт тот же.
|
||||
*/
|
||||
class BearerHeaderTest {
|
||||
|
||||
private val TestBearer = createRouteScopedPlugin(
|
||||
name = "TestBearer",
|
||||
createConfiguration = ::BearerCfg,
|
||||
) {
|
||||
val expected = pluginConfig.token
|
||||
onCall { call ->
|
||||
if (expected == null) return@onCall
|
||||
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BearerCfg {
|
||||
var token: String? = null
|
||||
}
|
||||
|
||||
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||
val server = embeddedServer(ServerCIO, port = 0) {
|
||||
routing {
|
||||
route("/agentik") {
|
||||
install(TestBearer) { token = "secret" }
|
||||
get("/conversations") {
|
||||
call.respondText("[]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
return server to port
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient("secret")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
assertEquals("[]", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient(null)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient("wrong")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,158 @@
|
||||
# 01 — Слои модулей (целевое состояние)
|
||||
|
||||
Целевая модульная структура agentik. Снизу вверх:
|
||||
**приложения → runtime → домен → абстракции → платформенные impl**.
|
||||
|
||||

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

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

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

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

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml android-agent-stack
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Android Agent Stack — что меняется vs Standalone
|
||||
|
||||
' --- Android side ---
|
||||
package "Android Application" {
|
||||
[MainActivity\n(Compose)] as Activity
|
||||
[AndroidAgentRunner\n(workmanager / service)] as Runner
|
||||
[AndroidAgentBuilder] as AndroidBuilder
|
||||
}
|
||||
|
||||
package "Android-specific impls" {
|
||||
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
|
||||
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
|
||||
[LitertAndroid\n(NNAPI delegate)] as LitertA
|
||||
[SoulFileAndroid\n(context.filesDir)] as SoulA
|
||||
[McpRegistry\nstdio: ProcessBuilder] as McpA
|
||||
}
|
||||
|
||||
' --- Shared (KMP) ---
|
||||
package "Agent Runtime (shared)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder] as Builder
|
||||
}
|
||||
|
||||
package "Domain (shared)" {
|
||||
[LlmTools\ncommonMain] as LlmT
|
||||
[BackgroundEvents\ncommonMain] as Ev
|
||||
[McpBridge\njvmMain] as McpB
|
||||
[Skills\ncommonMain] as Skills
|
||||
}
|
||||
|
||||
package "Memory (shared impl)" {
|
||||
[MemoryMd\n(commonMain)] as MemMd
|
||||
[MemoryApi\ninterfaces] as MemApi
|
||||
}
|
||||
|
||||
' --- Зависимости ---
|
||||
Activity --> Runner
|
||||
Runner --> AndroidBuilder
|
||||
AndroidBuilder --> AgentCore
|
||||
|
||||
AndroidBuilder --> StorageA
|
||||
AndroidBuilder --> MemVecA
|
||||
AndroidBuilder --> LitertA
|
||||
AndroidBuilder --> SoulA
|
||||
AndroidBuilder --> McpA
|
||||
|
||||
AgentCore --> LlmT
|
||||
AgentCore --> Ev
|
||||
AgentCore --> McpB
|
||||
AgentCore --> Skills
|
||||
AgentCore --> MemMd
|
||||
|
||||
' --- Главные отличия от Standalone ---
|
||||
note right of LitertA
|
||||
On-device inference.
|
||||
LiteRT с NNAPI delegate →
|
||||
работает на CPU/GPU/NPU
|
||||
прямо на устройстве, без сети.
|
||||
|
||||
vs Standalone: HTTP-only
|
||||
(OpenAI-compatible).
|
||||
end note
|
||||
|
||||
note right of StorageA
|
||||
android.database.sqlite
|
||||
через Room или сырой API.
|
||||
|
||||
vs Standalone: JDBC +
|
||||
Sqlite-JDBC driver
|
||||
(только JVM).
|
||||
end note
|
||||
|
||||
note right of MemVecA
|
||||
JVector JVM-only. На Android
|
||||
нужна альтернатива —
|
||||
ONNX Runtime + какой-нибудь
|
||||
ANN (Annoy/HNSW).
|
||||
|
||||
Или пока без vector memory,
|
||||
только MemoryMd.
|
||||
end note
|
||||
|
||||
note right of McpA
|
||||
MCP через ProcessBuilder
|
||||
на Android работает, но
|
||||
subprocess lifecycle
|
||||
сложнее (foreground service
|
||||
нужен для долгого subprocess).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что общего с `:standalone`
|
||||
|
||||
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
|
||||
|
||||
## Что другое
|
||||
|
||||
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|
||||
|---|---|---|---|
|
||||
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
|
||||
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
|
||||
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
|
||||
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
|
||||
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
|
||||
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
|
||||
|
||||
## Минимальный Android agent (v1)
|
||||
|
||||
Если не нужны все фичи сразу — минимум:
|
||||
|
||||
```kotlin
|
||||
val agent = androidAgentBuilder(context) {
|
||||
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
|
||||
storage(SqliteStorage.android(context, "agent.db"))
|
||||
memory(MemoryMd.root(context.filesDir.resolve("memory")))
|
||||
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
|
||||
background {
|
||||
// OnClosing + OnCompaction работают так же как на JVM
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
|
||||
|
||||
## Foreground service для MCP
|
||||
|
||||
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
|
||||
|
||||
```kotlin
|
||||
class McpForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
val proc = ProcessBuilder(command, args).start()
|
||||
// ... route stdio to McpLiteToolAdapter ...
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пока можно без этого (только если MCP нужен на Android).
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ KMP-ready:
|
||||
- `:llm-tools` (commonMain, платформо-агностик)
|
||||
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
|
||||
- `:skills` (commonMain)
|
||||
- `:memory-md` (commonMain)
|
||||
- `:proto` (commonMain)
|
||||
|
||||
⏳ Не существует:
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `:agent-core` (выделить BaseAgent + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,64 @@
|
||||
# agentik — диаграммы архитектуры
|
||||
|
||||
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
|
||||
|
||||
## Файлы
|
||||
|
||||
Каждый `.md` содержит:
|
||||
- Краткое описание (что показывает)
|
||||
- **Пред-рендеренный SVG** (``) — гарантированно показывается **везде**
|
||||
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
|
||||
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
|
||||
|
||||
| Файл | Что показывает |
|
||||
|---|---|
|
||||
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
|
||||
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
|
||||
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
|
||||
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
|
||||
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
|
||||
|
||||
## Почему SVG + PlantUML source
|
||||
|
||||
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
|
||||
|
||||
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
|
||||
|
||||
## Как редактировать диаграмму
|
||||
|
||||
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
|
||||
2. Ре-рендеришь SVG:
|
||||
```bash
|
||||
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
|
||||
cp docs/diagrams/*.md /tmp/plantuml-work/
|
||||
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
|
||||
cp /tmp/plantuml-work/*.svg docs/diagrams/
|
||||
```
|
||||
3. Проверяешь что SVG обновился:
|
||||
```bash
|
||||
ls -la docs/diagrams/*.svg
|
||||
```
|
||||
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
|
||||
|
||||
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий код движется в эту сторону:
|
||||
- `:llm-tools` extracted ✅
|
||||
- `:mcp-bridge` extracted ✅
|
||||
- `BackgroundScheduler` стал event-driven ✅
|
||||
- `ConversationLoop` стал отдельным компонентом ✅
|
||||
|
||||
Не сделано (см. детали в каждом .md):
|
||||
- `:agent-core` (выделить `BaseAgent` + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `MentionDetector` interface + adapters для multi-user chat
|
||||
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
|
||||
|
||||
Подробнее:
|
||||
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
|
||||
- `MEMORY-DESIGN.md` — детали memory архитектуры
|
||||
- `STANDALONE.md` — текущий standalone
|
||||
+6
-2
@@ -1,5 +1,9 @@
|
||||
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD.
|
||||
version=0.1.0
|
||||
# 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
|
||||
|
||||
@@ -13,11 +13,17 @@ 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" }
|
||||
|
||||
@@ -49,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||
@@ -56,6 +63,24 @@ 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 ---
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
//
|
||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
||||
// доступные JVM target'у.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":memory-api"))
|
||||
api(project(":storage-core"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,7 +27,7 @@ import kotlin.time.Clock
|
||||
*/
|
||||
class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
private val maxTurns: Int = 6,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val clock: Clock = Clock.System,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа от [LlmReflector].
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
|
||||
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
|
||||
// в набор [LiteTool]-адаптеров.
|
||||
//
|
||||
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
|
||||
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
|
||||
// или :agentik-tui когда те снова включатся.
|
||||
//
|
||||
// Зависимости:
|
||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
||||
// - litert.api для LiteTool контракта
|
||||
// - MCP SDK (JVM-only)
|
||||
// - Ktor client (для StreamableHttpClientTransport)
|
||||
// - kotlinx-serialization для парсинга конфига
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":agent-toolsets"))
|
||||
|
||||
api(libs.litert.api)
|
||||
|
||||
implementation(libs.mcp.sdk.client)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
import mu.KotlinLogging
|
||||
|
||||
@@ -30,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.standalone.agent.NamedTool
|
||||
import pw.binom.litert.LiteTool
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Реестр подключённых MCP-серверов.
|
||||
@@ -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,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,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+
|
||||
заметок) и семантических запросов.
|
||||
@@ -2,6 +2,16 @@ plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
||||
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
|
||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
||||
// Использование:
|
||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||
val skipVectorMemory: Boolean =
|
||||
(project.findProperty("skipVectorMemory") == "true") ||
|
||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
@@ -24,26 +34,14 @@ kotlin {
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.jvector)
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
// text-embedding-kmp — on-device SigLIP2 через ONNX Runtime.
|
||||
// Сигнатура `embed(String): TextEmbedding` (blocking), оборачиваем
|
||||
// наш `suspend fun embed(text)` через Mutex. api-вариант экспортируем
|
||||
// (`api`), потому что SiglipEmbeddingProvider реализует `embed()`
|
||||
// через тип TextEmbeddingExtractor, который виден потребителю
|
||||
// только если он сам подтянет api-jvm — проще пробросить.
|
||||
//
|
||||
// WORKAROUND: upstream `siglip-jvm/*.module` ссылается на `api`
|
||||
// БЕЗ -jvm суффикса. Поскольку в mavenLocal есть только `api-jvm`,
|
||||
// требуется дополнительный stub-jar `pw.binom.ai.embeddingtext:api`
|
||||
// с тем же содержимым. Создаётся так:
|
||||
// mkdir -p ~/.m2/repository/pw.binom.ai.embeddingtext/api/3.0.0-SNAPSHOT
|
||||
// cp ~/.m2/repository/.../api-jvm/3.0.0-SNAPSHOT/api-jvm-*.{jar,sources.jar} \
|
||||
// ~/.m2/repository/.../api/3.0.0-SNAPSHOT/api-*.{jar,sources.jar}
|
||||
// Когда upstream починит module-metadata — эту инструкцию можно убрать.
|
||||
api(libs.text.embedding.api)
|
||||
implementation(libs.text.embedding.siglip)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("jvmMainApi", libs.text.embedding.api)
|
||||
add("jvmMainImplementation", libs.text.embedding.siglip)
|
||||
}
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# `:proto` — протокол общения с агентом (KMP, jvm + native)
|
||||
|
||||
## Что это
|
||||
|
||||
Типы и контракт in-house протокола `agentik`, заменившего AG-UI:
|
||||
|
||||
- **stateful** — сервер сам владеет диалогом; клиент шлёт только новые
|
||||
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
|
||||
повторять `messages[]` каждый раз).
|
||||
- **declarative история vs. события** — `Message` это то, что уже легло
|
||||
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
|
||||
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
|
||||
`:proto`; это контракт.
|
||||
|
||||
Решает проблему: AG-UI клиент вынужден каждый раз знать и пересобирать
|
||||
полную историю, а его серверная часть (`AbstractAgent`) — постоянно
|
||||
сериализовать-десериализовать всю переписку. В `:proto` сервер один,
|
||||
контракт тонкий, переписка персистится нативно (SQLite, файлы, что
|
||||
хотите). Можно подменить front-end или back-end, протокол остаётся.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE.
|
||||
- `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`.
|
||||
- `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
|
||||
*(`:agentik-tui` был исключён из сборки 2026-09-17.)*
|
||||
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
|
||||
`Message`/`Event` напрямую через storage.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:proto:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Артефакт `pw.binom.agentik:proto:0.1.0` живёт в Nexus-репозитории
|
||||
`caffeine` (HTTP `http://<your-nexus>/repository/caffeine/`, plain-HTTP,
|
||||
credentials — через переменные `binom.repo.user/password/url`).
|
||||
|
||||
## Версии
|
||||
|
||||
Каталог `gradle/libs.versions.toml`, секция `[versions]` → `agentik-proto`.
|
||||
Поднять версию → переопубликовать все KMP-таргеты через `./gradlew
|
||||
:proto:publish -Pversion=...` (или триггернуть Gitea release).
|
||||
|
||||
Текущие KMP-таргеты: `jvm + macosX64/macosArm64 +
|
||||
iosX64/iosArm64/iosSimulatorArm64 + linuxX64/linuxArm64 + mingwX64`.
|
||||
|
||||
## Публикация
|
||||
|
||||
Настройки в `gradle.properties` / env: `binom.repo.url`, `binom.repo.user`,
|
||||
`binom.repo.password`. `./gradlew :proto:publish` публикует все
|
||||
target-specific артефакты + общий `kotlinMultiplatform`.
|
||||
|
||||
## Основные типы
|
||||
|
||||
```kotlin
|
||||
interface Agent {
|
||||
fun id: String
|
||||
suspend fun createConversation(title: String? = null): Conversation
|
||||
suspend fun getConversation(id: String): Conversation?
|
||||
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
|
||||
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
||||
}
|
||||
|
||||
interface Conversation : AutoCloseable {
|
||||
val id: String
|
||||
val updatedAt: Instant
|
||||
val isSupportImageInput: Boolean
|
||||
val isSupportImageOutput: Boolean
|
||||
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
||||
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
||||
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
||||
suspend fun rename(title: String): Boolean
|
||||
fun interrupt()
|
||||
}
|
||||
|
||||
sealed interface Content {
|
||||
class Text(val body: String) : Content
|
||||
class Image(val data: ByteArray, val mime: String) : Content
|
||||
}
|
||||
|
||||
sealed interface Message {
|
||||
val id: String
|
||||
val date: Instant
|
||||
interface Body : Message { val content: List<Content> }
|
||||
interface System : Message
|
||||
class UserMessage(...) : Body
|
||||
class AssistantMessage(...) : Body
|
||||
class ToolCall(...) : System
|
||||
class ToolResult(...) : System
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
enum ResponseType { TEXT, IMAGE }
|
||||
class StartReasoning(...) : Event
|
||||
class StartResponse(val type: ResponseType) : Event
|
||||
class AppendText(val body: String) : Event
|
||||
class AppendImage(val body: ByteArray, val mime: String) : Event
|
||||
class End(...) : Event
|
||||
class Interrupted(...) : Event
|
||||
class Error(val message: String, val code: Int? = null) : Event
|
||||
class ToolCall(...) : Event
|
||||
class ToolResult(...) : Event
|
||||
}
|
||||
```
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||
и `:client`.
|
||||
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
||||
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||
внутри `:standalone` (ChatAgent) и выше.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :proto:jvmTest
|
||||
./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
|
||||
```
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Иммутабельный API (после рефакторинга из
|
||||
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
|
||||
contents, server-pushed references — все обсуждаются через общий
|
||||
[IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
|
||||
@@ -0,0 +1,88 @@
|
||||
# `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
|
||||
|
||||
## Что это
|
||||
|
||||
Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
|
||||
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
|
||||
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
|
||||
`GET /agentik/conversations`.
|
||||
|
||||
- **stateful** — сервер не принимает полную историю, только новые
|
||||
сообщения. История хранится там, где развёрнут `Agent`.
|
||||
- **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
|
||||
обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
|
||||
с snake_case-дискриминаторами).
|
||||
|
||||
Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
|
||||
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подключает `Route.agentikAgent(agent)` в свой
|
||||
embedded Netty engine.
|
||||
- Любые клиенты (наши `:client`, `:agentik-cli`, или
|
||||
внешние web-фронтенды) идут через этот контракт.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts (KMP JVM target)
|
||||
plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
|
||||
dependencies {
|
||||
api("pw.binom.agentik:server:0.1.0")
|
||||
api("pw.binom.agentik:proto:0.1.0")
|
||||
}
|
||||
|
||||
// ваш код:
|
||||
fun Application.module(agent: Agent) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
install(SSE)
|
||||
routing {
|
||||
route("/agentik") { agentikAgent(agent) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-server`.
|
||||
|
||||
## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
|
||||
|
||||
| Метод | Путь | Что делает |
|
||||
|---|---|---|
|
||||
| `POST` | `/conversations` | Создать диалог (body: `{title?}`) |
|
||||
| `GET` | `/conversations` | Список диалогов (по `?offset=&limit=`) |
|
||||
| `GET` | `/conversations/:id` | Снимок диалога + count |
|
||||
| `GET` | `/conversations/:id/messages` | История сообщений (по `?after=`) |
|
||||
| `POST` | `/conversations/:id/rename` | Переименовать (body: `{title}`) |
|
||||
| `DELETE` | `/conversations/:id` | Удалить |
|
||||
| `POST` | `/conversations/:id/send` | Send-флоу (body: `{content:[…]}` → SSE) |
|
||||
| `GET` | `/conversations/:id/events` | Live подписка (SSE) |
|
||||
| `POST` | `/conversations/:id/interrupt` | Прервать текущий `send()` |
|
||||
|
||||
`Content-Type: text/event-stream` всегда для SSE, ноль-лишних
|
||||
заголовков. Сообщения: `event: <name>` (`message`, `start_reasoning`,
|
||||
`start_response`, `append_text`, `append_image`, `end`, `interrupted`,
|
||||
`error`) + `data: <JSON>`.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :server:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
|
||||
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
|
||||
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
|
||||
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
|
||||
добавляются только с snake_case-дискриминаторами и строго обратно
|
||||
совместимо.
|
||||
@@ -37,6 +37,8 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.ktor.server.test.host)
|
||||
implementation(libs.ktor.server.cio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package pw.binom.agentik.server
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.request.path
|
||||
import io.ktor.server.response.respondText
|
||||
|
||||
/**
|
||||
* Конфиг плагина проверки `Authorization: Bearer <token>` для роута `agentikAgent`.
|
||||
*
|
||||
* По умолчанию [token] == null → плагин пропускает все запросы (см. [Module.kt]).
|
||||
*/
|
||||
internal class BearerTokenConfig {
|
||||
var token: String? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-scoped плагин: если в конфиге задан [BearerTokenConfig.token], на каждый
|
||||
* запрос внутри ветки роута проверяет заголовок `Authorization: Bearer <token>`.
|
||||
* При несовпадении отвечает `401 Unauthorized` (тело `Unauthorized`); дальнейшие
|
||||
* обработчики не вызываются — ktor трактует отправленный ответ как завершение
|
||||
* call-pipeline.
|
||||
*
|
||||
* `/health` всегда пропускается без проверки: это ручка liveness для
|
||||
* балансировщика/мониторинга, закрывать её — сломать health-check.
|
||||
*/
|
||||
internal val BearerTokenPlugin = createRouteScopedPlugin(
|
||||
name = "AgentikBearerToken",
|
||||
createConfiguration = ::BearerTokenConfig,
|
||||
) {
|
||||
val expected = pluginConfig.token
|
||||
onCall { call ->
|
||||
if (expected == null) return@onCall
|
||||
val path = call.request.path()
|
||||
if (path.endsWith("/health")) return@onCall
|
||||
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,16 @@ import pw.binom.agentik.proto.Agent
|
||||
* - `GET /events` — SSE: события агента
|
||||
* - `GET /health` — `"ok"`
|
||||
*/
|
||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
|
||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? = null) {
|
||||
route(path) {
|
||||
install(ContentNegotiation) {
|
||||
json(agentikJson)
|
||||
}
|
||||
if (token != null) {
|
||||
install(BearerTokenPlugin) {
|
||||
this.token = token
|
||||
}
|
||||
}
|
||||
agentikRoutes(agent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package pw.binom.agentik.server
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Тесты route-scoped плагина [BearerTokenPlugin]:
|
||||
* - при `token != null` все роуты кроме `/health` требуют `Authorization: Bearer <token>`;
|
||||
* - при `token == null` плагин не устанавливается, всё открыто;
|
||||
* - `/health` всегда открыт, даже при заданном токене (liveness-ручка для балансировщика).
|
||||
*/
|
||||
class BearerTokenTest {
|
||||
|
||||
private class FakeAgent(override val id: String = "test") : Agent {
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
override suspend fun deleteConversation(id: String): Boolean = false
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList()
|
||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||
}
|
||||
|
||||
private suspend fun startServer(token: String?): Pair<EmbeddedServer<*, *>, Int> {
|
||||
val server = embeddedServer(ServerCIO, port = 0) {
|
||||
routing {
|
||||
agentikAgent(FakeAgent(), path = "/agentik", token = token)
|
||||
}
|
||||
}.start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
return server to port
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenRejectsRequestWithoutHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
assertEquals("Unauthorized", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenRejectsWrongHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||
header(HttpHeaders.Authorization, "Bearer wrong")
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenAcceptsCorrectHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||
header(HttpHeaders.Authorization, "Bearer secret")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun healthStaysOpenWithToken() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/health")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
assertEquals("ok", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullTokenMeansOpen() = runBlocking {
|
||||
val (server, port) = startServer(null)
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "agentik"
|
||||
|
||||
include(":standalone")
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
include(":llm-tools")
|
||||
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
|
||||
// из :standalone — MCP не специфичен для standalone'а, это generic мост
|
||||
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool).
|
||||
include(":mcp-bridge")
|
||||
// Собственный протокол agentik. Пока в нём пилим, потом вынесем.
|
||||
include(":proto")
|
||||
// Парсер скилов (YAML-frontmatter + markdown body, opencode-style).
|
||||
@@ -36,6 +44,14 @@ include(":skills")
|
||||
include(":server")
|
||||
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
||||
include(":client")
|
||||
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
||||
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
||||
include(":agentik-cli")
|
||||
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
|
||||
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
|
||||
// include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
|
||||
// Папка agentik-tui/ оставлена на диске для возможного возврата; из сборки исключена.
|
||||
|
||||
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
||||
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
||||
include(":memory-api")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# `:skills` — парсер SKILL.md (KMP, JVM-only)
|
||||
|
||||
## Что это
|
||||
|
||||
Парсер и runtime для навыков агента в формате [opencode Skills](https://docs.opencode.dev):
|
||||
|
||||
- **SKILL.md / \*.yaml** с YAML-frontmatter (`name`, `description`,
|
||||
`allowed-tools`, etc.) и markdown-телом.
|
||||
- Реестр `SkillCatalog`, лоадер `SkillLoader` (поиск по
|
||||
`~/.agentik/skills/`).
|
||||
- `Skill` имеет стабильный id, описание, может требовать определённые
|
||||
tools (`allowed-tools: [run_command, write_file]`) — это контролируется
|
||||
на уровне вызова.
|
||||
- Загруженные скиллы аггрегируются в system-prompt через
|
||||
`Skill.toSystemPromptSection()` или подгружаются по требованию через
|
||||
tool `read_skill`.
|
||||
|
||||
Решает задачу: агенту нужно объяснить "что я умею" на разных языках
|
||||
(нативный skill-вызов vs. описание), нужно уметь включать/выключать
|
||||
навыки по требованию, и нужно хранить текстовые навыки прямо в
|
||||
git-репозитории (а не в БД).
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подгружает все SKILL.md из `~/.agentik/skills/`
|
||||
и инструментового скилл-майнера (skill-mining: создание новых
|
||||
SKILL.md по LLM-рефлексии).
|
||||
- Активно юзается для: `code-review`, `arch-summary`, `telegram-reply`,
|
||||
любых "habits" агента.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:skills:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-skills`.
|
||||
|
||||
## Пример SKILL.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-review
|
||||
description: Review uncommitted diff and produce line-anchored comments.
|
||||
allowed-tools: [run_command, read_file]
|
||||
---
|
||||
|
||||
You are a strict reviewer. For every change in the diff, output:
|
||||
- File: <path>
|
||||
- Severity: <nit|warning|blocker>
|
||||
- Comment: <one sentence>
|
||||
|
||||
Only mention issues that are objectively wrong. Do not refactor.
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :skills:jvmTest
|
||||
```
|
||||
|
||||
Покрывают: парсинг yaml-frontmatter, обработку отсутствующих полей,
|
||||
unicode-имена, дубликаты id, очень большое тело.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого HTTP / tool-вызова. Парсер и реестр — не более.
|
||||
- Никакой БД. SKILL.md живут в файлах под управлением пользователя.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Парсер простой и предсказуемый; расширять
|
||||
формат frontmatter можно без поломок (новые поля игнорируются).
|
||||
+125
-460
@@ -1,481 +1,146 @@
|
||||
# :standalone — agentik single-jar server
|
||||
# `:standalone` — single-jar HTTP-сервер со всеми транспортами
|
||||
|
||||
Self-contained HTTP-сервер с Ktor: AG-UI / A2A / :proto транспорты на одном порту,
|
||||
встроенный SQLite для истории диалогов, долговременная память (Hermes-style
|
||||
§-файлы), загрузка MCP-инструментов, навыков (SKILL.md) и персоны (SOUL.md).
|
||||
## Что это
|
||||
|
||||
## Сборка
|
||||
Главный исполняемый модуль проекта — single-jar HTTP-сервер с:
|
||||
|
||||
- **AG-UI** transport на `POST /agui` (SSE) + `GET /health`.
|
||||
- **A2A** transport на `POST /` (JSON-RPC) + `GET /.well-known/agent-card.json`.
|
||||
- **`:proto`** transport на `POST /agentik/*` (HTTP+JSON+SSE) — наш stateful.
|
||||
- **Embedded LLM backend**: `GOOGLE` (LiteRT) или `OPENAI`-совместимый
|
||||
(vLLM, LiteLLM, OpenAI API).
|
||||
- **SQLite persistence** через `:storage-sqlite`.
|
||||
- **Memory backend**: `md` (файловый) или `vector` (SQLite+JVector+
|
||||
HTTP/SIGLIP-embeddings).
|
||||
- **Skills** из `~/.agentik/skills/*.md`.
|
||||
- **SOUL** из `~/.agentik/SOUL.md`.
|
||||
- **Background подпроцессы**: рефлексия, skill-mining,
|
||||
memory-reviewer.
|
||||
|
||||
Решает: даёт пользователю один JAR (10–250 МБ), который запускается
|
||||
через `java -jar agentik-0.1.0-all.jar`, и поднимает сразу все
|
||||
транспорты, которые другие системы могут хавать.
|
||||
|
||||
## Как запустить
|
||||
|
||||
### Требования
|
||||
|
||||
- JVM 21+.
|
||||
- (Опционально) CUDA-устройство для `:backend=google` (LiteRT).
|
||||
- (Опционально) LM через OpenAI-совместимый endpoint (vLLM / Ollama
|
||||
/ OpenAI) для `:backend=openai`.
|
||||
|
||||
### Запуск из готового fatjar
|
||||
|
||||
```bash
|
||||
# Полная сборка всего проекта + fatjar
|
||||
./gradlew assemble
|
||||
|
||||
# Только fatjar :standalone (≈ 150 MB)
|
||||
./gradlew :standalone:shadowJar
|
||||
|
||||
# Результат:
|
||||
# standalone/build/libs/standalone-all.jar
|
||||
java --enable-native-access=ALL-UNNAMED \
|
||||
-jar agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
## Запуск
|
||||
С дефолтами — встроенный SQLite, OpenAI-compatible backend на
|
||||
`http://localhost:8001/v1`, порт 8080.
|
||||
|
||||
### Запуск через Gradle (dev)
|
||||
|
||||
```bash
|
||||
java -jar standalone/build/libs/standalone-all.jar
|
||||
./gradlew :standalone:run
|
||||
```
|
||||
|
||||
По умолчанию слушает на `http://localhost:8080`. Healthcheck: `GET /health`.
|
||||
### `pull-model` subcommand (для LiteRT)
|
||||
|
||||
Транспорты на одном порту:
|
||||
- `GET /health` — liveness
|
||||
- `POST /agentik/conversations` — создать беседу
|
||||
- `GET /agentik/conversations/{id}/events` — SSE-стрим ответов
|
||||
- `POST /a2a/` — A2A JSON-RPC (`message/send`, `tasks/get`, `tasks/cancel`)
|
||||
- `GET /a2a/.well-known/agent-card.json` — AgentCard
|
||||
```bash
|
||||
# Сначала скачать модель под LiteRT-Gemma-4-E2B
|
||||
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
|
||||
```
|
||||
|
||||
### A2A
|
||||
Скачивает `https://static.binom.pw/models/gemma-4-E2B-it.litertlm`
|
||||
(2.5 ГБ, с Range-resume). Поддерживает override через
|
||||
`AGENTIK_GOOGLE_MODEL_URL` и verify через
|
||||
`AGENTIK_GOOGLE_MODEL_SHA256_URL`.
|
||||
|
||||
Адаптер `A2aBridge` гоняет A2A-контекст на диалог :proto: `contextId` мапится на
|
||||
`Conversation` (пустой/неизвестный `contextId` → новый диалог). Ответ — склеенный
|
||||
текст хода; id внутреннего диалога возвращается в `metadata.agentikConversationId`
|
||||
ответа. Задачи живут в in-memory `TaskStore` (не переживают рестарт процесса).
|
||||
## Переменные среды
|
||||
|
||||
## Переменные окружения
|
||||
Полный список — общий для всего `:standalone`-процесса:
|
||||
|
||||
Все переменные читаются `AgentikConfig.fromEnv()`. Бланк или отсутствие → дефолт.
|
||||
|
||||
| Переменная | Дефолт | Назначение |
|
||||
| Env | Default | Что делает |
|
||||
|---|---|---|
|
||||
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
|
||||
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite (история бесед + метаданные памяти) |
|
||||
| `AGENTIK_SKILLS_DIR` | _выкл._ | Каталог со скилами (`SKILL.md` / `*.yaml`) |
|
||||
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory` (md) или `AGENTIK_DB_PATH` (vector) | Каталог памяти (md); `"off"` отключает |
|
||||
| `AGENTIK_MEMORY_BACKEND` | `md` | `md` (Hermes-style §-файлы) / `vector` (SQLite + JVector + LLM-эмбеддинги) / `off` |
|
||||
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда (только HTTP) |
|
||||
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (только HTTP; SIGLIP определяет автоматически) |
|
||||
| `AGENTIK_EMBEDDING_BACKEND` | `HTTP` | `HTTP` (POST /v1/embeddings) или `SIGLIP` (on-device, без сети) |
|
||||
| `AGENTIK_EMBEDDING_MODEL_PATH` | _только SIGLIP_ | Путь к `text_model_int8.onnx` (SigLIP2) |
|
||||
| `AGENTIK_EMBEDDING_TOKENIZER_PATH` | _только SIGLIP_ | Путь к `tokenizer.model` (sentencepiece) |
|
||||
| `AGENTIK_SOUL` | _выкл._ | Путь к `SOUL.md` — файл персоны (markdown), вставляется в начало system prompt |
|
||||
| `AGENTIK_LLM_BACKEND` | — | `openai` или `google` (см. ниже) |
|
||||
| `AGENTIK_MCP_CONFIG` | _выкл._ | Путь к JSON со списком MCP-серверов |
|
||||
| `AGENTIK_SYSTEM_PROMPT` | `be brief` | Базовый system prompt |
|
||||
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) |
|
||||
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend |
|
||||
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction |
|
||||
| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. |
|
||||
| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. |
|
||||
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Skill mining: через сколько user-ходов запускать фоновый прогон SkillMiner. `0` = выключено. |
|
||||
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов передавать SkillMiner'у за один прогон. |
|
||||
| `AGENTIK_DEBUG_ENDPOINTS` | `0` | `1` включает debug-эндпоинты (`/debug/reflect`, `/debug/skill-mine`, `/debug/curate`, `/debug/compact`, `/debug/tokens`) |
|
||||
|
||||
### OpenAI backend
|
||||
|
||||
| Переменная | Обязательна | Назначение |
|
||||
|---|---|---|
|
||||
| `OPENAI_BASE_URL` | да | Например, `https://api.openai.com/v1` |
|
||||
| `OPENAI_API_KEY` | да | API key |
|
||||
| `OPENAI_MODEL` | да | Имя модели (`gpt-4o-mini` и т.п.) |
|
||||
|
||||
### Google backend
|
||||
|
||||
| Переменная | Обязательна | Назначение |
|
||||
|---|---|---|
|
||||
| `AGENTIK_GOOGLE_MODEL_PATH` | да | Путь к `.litertlm` файлу |
|
||||
|
||||
## SQLite: путь к базе диалогов
|
||||
|
||||
`AGENTIK_DB_PATH` указывает на файл SQLite, в котором хранятся таблицы
|
||||
`conversations`, `messages`, `working_memory`. SQLDelight-драйвер создаёт
|
||||
файл при первом запуске. Если в пути есть несуществующие директории — их
|
||||
нужно создать заранее (`mkdir -p`).
|
||||
|
||||
```bash
|
||||
# Абсолютный путь
|
||||
AGENTIK_DB_PATH=/var/lib/agentik/state.db
|
||||
|
||||
# Относительный путь — резолвится от CWD
|
||||
cd /opt/agentik && AGENTIK_DB_PATH=./data/state.db
|
||||
|
||||
# Временная база (на RAM, теряется при рестарте) — не поддерживается напрямую,
|
||||
# но можно подменить в коде через SqliteStores.inMemory().
|
||||
```
|
||||
|
||||
Файл базы — обычный SQLite, можно инспектировать `sqlite3` CLI или Adminer.
|
||||
|
||||
## Контекст инициации сообщения (MessageContext)
|
||||
|
||||
Каждое user-сообщение может нести **контекст инициации хода** —
|
||||
кто/что его вызвало. Для обычного user-сообщения поле `context` опускается
|
||||
(обратная совместимость: старые клиенты, шлющие голый массив Content,
|
||||
работают как раньше). Для cron/webhook/system событий `context` обязательно.
|
||||
|
||||
```json
|
||||
// POST /agentik/conversations/{id}/messages — новый формат
|
||||
{
|
||||
"content": [{"type": "text", "body": "wake up"}],
|
||||
"context": {
|
||||
"origin": "event",
|
||||
"description": "scheduled cron morning-briefing",
|
||||
"sourceId": "cron-42",
|
||||
"metadata": { "scheduledAt": "2026-09-14T08:00:00Z" }
|
||||
}
|
||||
}
|
||||
|
||||
// Старый формат (всё ещё работает) — голый массив
|
||||
[{"type": "text", "body": "hello"}]
|
||||
```
|
||||
|
||||
| `origin` | Когда использовать | Префикс в LLM |
|
||||
|------------|---------------------------------------------------|--------------|
|
||||
| `user` | Обычное сообщение из чата (дефолт) | нет |
|
||||
| `system` | Программное сообщение (старт агента, режим обслуживания) | `[SYSTEM] description (sourceId=…)` |
|
||||
| `event` | Cron, webhook, file-changed, внешний триггер | `[EVENT] description (sourceId=…)` |
|
||||
|
||||
**Семантика:**
|
||||
|
||||
- `origin` — кто/что инициировал ход. `user` = человек в чате (UI/IRC/HTTP).
|
||||
- `description` — короткая человекочитаемая фраза для модели
|
||||
(обязательна для `system`/`event`).
|
||||
- `sourceId` — id cron-job'а / webhook endpoint'а / IRC-канала, помогает
|
||||
в логах и при ручном разборе.
|
||||
- `metadata` — произвольный JSON, никогда не попадает в LLM-нагрузку
|
||||
(только в audit log для пост-аналитики).
|
||||
|
||||
**Что происходит при не-USER origin'е:**
|
||||
|
||||
В working memory текст user-сообщения предваряется префиксом —
|
||||
например, `[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\n…`.
|
||||
Модель видит, что её разбудил не пользователь, и может реагировать
|
||||
иначе (например, не начинать диалог с приветствия). Префикс добавляется
|
||||
**только к LLM-нагрузке**, в audit log и при `getMessages` возвращается
|
||||
оригинальный текст + `context` отдельно.
|
||||
|
||||
## Сжатие рабочего контекста (compaction)
|
||||
|
||||
Когда диалог становится длинным, working memory диалога может превысить
|
||||
контекстное окно модели. Чтобы этого не случилось, агент умеет **сжимать**
|
||||
старые ходы в один синтетический `Summary`-блок.
|
||||
|
||||
**Включается только при заданном лимите.** Никакого автодетекта по имени
|
||||
модели — если лимит не задан, агент не сжимает.
|
||||
|
||||
```bash
|
||||
# OpenAI-совместимый бэкенд
|
||||
export OPENAI_CONTEXT_WINDOW=128000
|
||||
|
||||
# Или Google / LiteRT
|
||||
export AGENTIK_GOOGLE_CONTEXT_WINDOW=32000
|
||||
```
|
||||
|
||||
`AGENTIK_COMPRESSION_THRESHOLD` — доля лимита, при которой запускается
|
||||
compaction (дефолт `0.8` = 80%):
|
||||
|
||||
```bash
|
||||
export AGENTIK_COMPRESSION_THRESHOLD=0.7 # сжимаем раньше
|
||||
```
|
||||
|
||||
**Что происходит при compaction:**
|
||||
|
||||
1. Перед `send()` оценивается количество токенов в системном промпте + history
|
||||
+ tools (грубая оценка `chars / 4`).
|
||||
2. Если `estimated / contextWindow ≥ threshold` — асинхронный шаг:
|
||||
- Старые ходы (User/Assistant, кроме последних 4) скармливаются в
|
||||
`LiteLlmContextCompactor` — отдельный one-shot LLM-вызов с промптом
|
||||
«Goal / Active / Resolved / Blocked / Remaining».
|
||||
- Параллельно `MemoryReviewer.reviewPreCompaction` извлекает из старых
|
||||
ходов факты и кладёт их в долговременную память (триггер `MemoryStore`).
|
||||
- Атомарный `working_memory.compact(fromIdx, summary)` — старые строки
|
||||
удаляются, на их место вставляется одна `Summary` запись.
|
||||
- `LiteConversation` пересоздаётся с обновлённым контекстом.
|
||||
|
||||
Если после compaction оценка всё ещё выше порога — выводится warning, но
|
||||
нового compaction не запускается (защита от зацикливания). Решение —
|
||||
поднять `OPENAI_CONTEXT_WINDOW` или понизить threshold.
|
||||
|
||||
## Self-reflection (Hermes-style «слабые места»)
|
||||
|
||||
Каждые `AGENTIK_REFLECTION_INTERVAL` пользовательских ходов (default 10)
|
||||
запускается фоновая one-shot LLM-размышление: «оцени последние ходы,
|
||||
поставь score 1..5, выдели слабые места». Результат сохраняется в таблицу
|
||||
`reflection` SQLite и подмешивается в system prompt следующего хода как
|
||||
«Твои слабые места за последнее время».
|
||||
|
||||
Включено когда `AGENTIK_REFLECTION_INTERVAL > 0`. Требует LiteLlm
|
||||
(on-device или OpenAI — что указан в `AGENTIK_LLM_BACKEND`). На каждый
|
||||
reflection — один LiteLlm вызов (~1-3 сек для on-device, ~200-500мс для
|
||||
OpenAI). Это происходит в фоне (`Dispatchers.IO`), основной диалог не
|
||||
блокируется.
|
||||
|
||||
Топ-K последних рефлексий загружается в `buildSystemPrompt` и выводится
|
||||
как `## Self-reflection: твои слабые места за последнее время`. Агент
|
||||
видит их в каждом следующем ходе и (теоретически) должен избегать
|
||||
повторения. Используется как cheap "auto-improving prompt feedback"
|
||||
без ручного переписывания system prompt.
|
||||
|
||||
## Учёт токенов (token accounting)
|
||||
|
||||
Каждый assistant-ход после LiteLlm.send помечает assistant-запись `TurnTokens(input, output)`:
|
||||
|
||||
- **`input`** — снимок `LiteConversation.tokenCount()` **до** первого send в turn'е
|
||||
(system + вся история + tools + только что добавленное user-сообщение).
|
||||
- **`output`** — дельта после завершения turn'а (assistant text + tool calls +
|
||||
tool results, всё что LiteConversation добавила за весь tool loop).
|
||||
- Хранится в `payload_json` assistant-сообщения (без schema-миграций). Бэкенды
|
||||
без `tokenCount()` (off-line модели LiteRT-LM счётчик не отдают) дают `tokens=null`.
|
||||
|
||||
На старте агент печатает сводку по всем существующим диалогам:
|
||||
|
||||
```
|
||||
tokens: 17 convs, 134 turns, in=523844, out=58290, total=582134
|
||||
```
|
||||
|
||||
`MessageStore.tokenStats(conversationId)` отдаёт `TokenStats(turns, inputTokens, outputTokens)`
|
||||
для одного диалога — можно использовать из HTTP фасада или клиентских дашбордов
|
||||
для оценки cost.
|
||||
|
||||
## Куратор памяти (Curator)
|
||||
|
||||
Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`):
|
||||
раз в сутки архивирует заметки, которые **не выдавались в prefetch дольше 90
|
||||
дней** и **имеют `useCount == 0`**. Семантика архивации зависит от бэкенда —
|
||||
`:memory-md` переименовывает §-файл в `.archived.{ts}`, `:memory-vector`
|
||||
удаляет из SQLite и JVector.
|
||||
|
||||
Параметры пока захардкожены в `Curator.DEFAULT_INTERVAL` и
|
||||
`Curator.DEFAULT_MAX_AGE` (1 день и 90 дней); для override нужен новый
|
||||
config-флаг. На каждом проходе выводится `[Curator] archived N stale notes`,
|
||||
если N > 0.
|
||||
|
||||
## Память
|
||||
|
||||
`AGENTIK_MEMORY_DIR` указывает на каталог, в котором лежат три §-файла:
|
||||
`user.md`, `world.md`, `preference.md` (по одному на категорию из `MemoryCategory`).
|
||||
Формат файла — Hermes-style: заголовок с метаданными (` id=… created=… uses=…`),
|
||||
пустая строка, markdown-тело заметки.
|
||||
|
||||
```bash
|
||||
# Дефолт (если env не задан)
|
||||
~/.agentik/memory/{user,world,preference}.md
|
||||
|
||||
# Явный путь
|
||||
AGENTIK_MEMORY_DIR=/data/agentik/memory
|
||||
|
||||
# Полностью выключить память (тулы memory_* не регистрируются, prefetch off)
|
||||
AGENTIK_MEMORY_DIR=off
|
||||
```
|
||||
|
||||
При включённой памяти агенту доступны четыре тула: `memory_save`,
|
||||
`memory_read`, `memory_list`, `memory_delete`. Перед каждым ходом агент
|
||||
прогоняет текст пользователя через `MemoryPrefetcher` и клеит `[Memory
|
||||
context…]` блок в начало user-сообщения; после записи assistant-сообщения в
|
||||
фоне запускается `MemoryReviewer.review(turn)` — извлечённые факты
|
||||
записываются в store с `source = AUTO_REVIEW`.
|
||||
|
||||
### Бэкенд: md vs vector
|
||||
|
||||
`AGENTIK_MEMORY_BACKEND` выбирает хранилище. Дефолт — `md` (Hermes-style
|
||||
§-файлы, keyword overlap, без внешних вызовов).
|
||||
|
||||
`vector` — SQLite (`AGENTIK_DB_PATH`) + JVector ANN + эмбеддинги. Два
|
||||
бэкенда эмбеддингов через `AGENTIK_EMBEDDING_BACKEND`:
|
||||
|
||||
- **`HTTP` (default)** — POST на `${OPENAI_BASE_URL}/v1/embeddings`. Семантический
|
||||
поиск: cosine similarity + recency-re-rank.
|
||||
- **`SIGLIP`** — on-device SigLIP2 через ONNX Runtime (text-embedding-kmp,
|
||||
768-мерный вектор). Никаких внешних вызовов: модель и токенизатор должны
|
||||
лежать на диске. Размерность определяется автоматически (768).
|
||||
|
||||
```bash
|
||||
# Vector-бэкенд + HTTP-эмбеддинги (default)
|
||||
AGENTIK_MEMORY_BACKEND=vector \
|
||||
AGENTIK_EMBEDDING_BACKEND=http \
|
||||
AGENTIK_EMBEDDING_MODEL=text-embedding-3-small \
|
||||
AGENTIK_EMBEDDING_DIMENSION=1536 \
|
||||
java -jar standalone-all.jar
|
||||
|
||||
# Vector-бэкенд + on-device SigLIP2 (без сети)
|
||||
AGENTIK_MEMORY_BACKEND=vector \
|
||||
AGENTIK_EMBEDDING_BACKEND=siglip \
|
||||
AGENTIK_EMBEDDING_MODEL_PATH=/path/to/text_model_int8.onnx \
|
||||
AGENTIK_EMBEDDING_TOKENIZER_PATH=/path/to/tokenizer.model \
|
||||
java -jar standalone-all.jar
|
||||
```
|
||||
|
||||
Для HTTP-эмбеддингов требуется `AGENTIK_LLM_BACKEND=openai` (т.к. нужен
|
||||
OpenAI-совместимый `/v1/embeddings` endpoint — LiteLLM proxy тоже подходит).
|
||||
HTTP-вызовы кэшируются LRU на 256 текстов — дедупликация при повторных
|
||||
запросах одинаковых промптов.
|
||||
|
||||
Для SIGLIP нужно сначала скачать модель (~283M) и токенизатор (~4M):
|
||||
```bash
|
||||
mkdir -p /path/to/siglip-model
|
||||
curl -fSL -o /path/to/siglip-model/text_model_int8.onnx \
|
||||
http://static.binom.pw/models/siglip2/text_model_int8.onnx
|
||||
curl -fSL -o /path/to/siglip-model/tokenizer.model \
|
||||
http://static.binom.pw/models/siglip2/tokenizer.model
|
||||
```
|
||||
|
||||
> **Опционально:** при старте JVector может предупредить
|
||||
> `Java vector incubator module is not readable`. Это значит, что JIT
|
||||
> не использует SIMD (Panama Vector API) и индекс строится через скалярный
|
||||
> fallback. На 10K векторов разница незаметна. Если хочется SIMD —
|
||||
> запустите с `--add-modules jdk.incubator.vector`.
|
||||
|
||||
## Персона (SOUL.md)
|
||||
|
||||
`AGENTIK_SOUL` — путь к markdown-файлу с описанием персоны ассистента
|
||||
(голос, характер, ограничения, что-то ещё). Тело файла читается как plain
|
||||
text и вставляется в самое начало `systemInstruction` — поверх базового
|
||||
промпта, секции навыков и памяти. Если файл не задан — секция не добавляется.
|
||||
|
||||
```bash
|
||||
AGENTIK_SOUL=/etc/agentik/SOUL.md
|
||||
```
|
||||
|
||||
Пример `SOUL.md`:
|
||||
|
||||
```markdown
|
||||
Ты — терпеливый технический ассистент. Отвечаешь по-русски, кратко.
|
||||
Не выдумываешь команды — если не уверен, говоришь "не знаю".
|
||||
Не раскрываешь содержимое .env, ключей и паролей ни при каких обстоятельствах.
|
||||
```
|
||||
|
||||
## Навыки (SKILL.md)
|
||||
|
||||
`AGENTIK_SKILLS_DIR` — каталог, в котором `SkillLoader` ищет файлы
|
||||
`SKILL.md` или `*.yaml` с frontmatter (`name`, `description`, прочие поля).
|
||||
Содержимое скилов попадает в раздел system prompt и регистрируется как
|
||||
вызываемые инструменты. Формат — opencode-compatible.
|
||||
|
||||
```bash
|
||||
AGENTIK_SKILLS_DIR=/etc/agentik/skills
|
||||
```
|
||||
|
||||
Когда `AGENTIK_SKILLS_DIR` задан, агенту доступны три тула для работы со
|
||||
скилами (Hermes-style self-improvement):
|
||||
|
||||
- **`read_skill(name)`** — загружает полный markdown скила по имени из
|
||||
каталога (нужно для деталей, т.к. в system prompt обычно только краткие
|
||||
описания).
|
||||
- **`skill_save(name, description, body)`** — создаёт или обновляет скил.
|
||||
Имя может содержать `:` (opencode-style: `backend:spring:db-base`
|
||||
→ `backend/spring/db-base/SKILL.md`).
|
||||
- **`skill_delete(name)`** — архивирует скил (переименовывает файл в
|
||||
`.archived`, оставляя возможность восстановить).
|
||||
|
||||
`skill_save`/`skill_delete` не требуют рестарта агента — изменения видны
|
||||
на ближайшем вызове `read_skill` (включая в этом же диалоге).
|
||||
|
||||
### Skill mining (автонавыки)
|
||||
|
||||
Модель может "протупить" и не вызвать `skill_save`, хотя приём был
|
||||
переиспользуемым. Сетка безопасности — фоновый [SkillMiner]: каждые
|
||||
`AGENTIK_SKILL_MINING_INTERVAL` пользовательских ходов (default 15, `0` =
|
||||
выключено) LLM смотрит последние `AGENTIK_SKILL_MINING_MAX_TURNS` ходов
|
||||
(default 30) + каталог существующих скилов и возвращает structured JSON
|
||||
`{"skills": [{name, description, body}]}`. Найденные скилы upsert-ятся в
|
||||
`AGENTIK_SKILLS_DIR` — агент становится умнее между сессиями. Обновления
|
||||
существующих скилов (то же имя) поддерживаются, дубли — нет.
|
||||
|
||||
| Переменная | Default | Что делает |
|
||||
|---|---|---|
|
||||
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Через сколько user-ходов запускать mining. `0` — выкл. |
|
||||
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов показывать минеру |
|
||||
|
||||
### Debug-эндпоинты
|
||||
|
||||
`AGENTIK_DEBUG_ENDPOINTS=1` включает эндпоинты для ручного триггерирования
|
||||
фоновых фич (не ждать интервалов). Только локальная отладка: без
|
||||
авторизации, в проде не включать.
|
||||
|
||||
| Эндпоинт | Действие |
|
||||
|---|---|
|
||||
| `POST /debug/reflect?conversationId=...` | прогон LlmReflector прямо сейчас, результат в БД |
|
||||
| `POST /debug/skill-mine?conversationId=...` | прогон SkillMiner прямо сейчас, найденное в `AGENTIK_SKILLS_DIR` |
|
||||
| `POST /debug/curate` | прогон Curator.runPass (архивация stale-заметок памяти) |
|
||||
| `POST /debug/compact?conversationId=...` | принудительный compaction working memory диалога |
|
||||
| `GET /debug/tokens?conversationId=...` | token-статистика диалога из БД (turns/in/out/total) |
|
||||
|
||||
Каждый возвращает JSON с результатом (что сохранил/нашёл/сжал), чтобы было
|
||||
видно не только "триггер сработал", а что именно LLM намайнила.
|
||||
|
||||
```bash
|
||||
AGENTIK_SKILLS_DIR=/etc/agentik/skills
|
||||
AGENTIK_DEBUG_ENDPOINTS=1
|
||||
```
|
||||
|
||||
|
||||
## MCP-инструменты
|
||||
|
||||
`AGENTIK_MCP_CONFIG` — путь к JSON-файлу со списком MCP-серверов
|
||||
(формат `mcpServers: { name: { command, args | url, headers } }`). При
|
||||
запуске `McpRegistry.fromConfig` стартует stdio-серверы и подключается к
|
||||
HTTP-серверам, инструменты автоматически становятся доступны агенту.
|
||||
|
||||
```bash
|
||||
AGENTIK_MCP_CONFIG=/etc/agentik/mcp.json
|
||||
```
|
||||
|
||||
Пример `mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": { "command": "uvx", "args": ["mcp-server-fetch"] },
|
||||
"playwright": { "url": "https://mcp.example.com", "headers": {"Authorization":"Bearer …"} }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Полный пример запуска
|
||||
|
||||
```bash
|
||||
export AGENTIK_PORT=8080
|
||||
export AGENTIK_DB_PATH=/var/lib/agentik/state.db
|
||||
export AGENTIK_MEMORY_DIR=/var/lib/agentik/memory
|
||||
export AGENTIK_SOUL=/etc/agentik/SOUL.md
|
||||
export AGENTIK_SKILLS_DIR=/etc/agentik/skills
|
||||
export AGENTIK_MCP_CONFIG=/etc/agentik/mcp.json
|
||||
|
||||
export AGENTIK_LLM_BACKEND=openai
|
||||
export OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
export OPENAI_API_KEY=sk-…
|
||||
export OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
mkdir -p "$(dirname "$AGENTIK_DB_PATH")" \
|
||||
"$AGENTIK_MEMORY_DIR" \
|
||||
"$(dirname "$AGENTIK_SOUL")" \
|
||||
"$(dirname "$AGENTIK_MCP_CONFIG")"
|
||||
|
||||
java -jar standalone/build/libs/standalone-all.jar
|
||||
```
|
||||
|
||||
На старте выведет что-то вроде:
|
||||
|
||||
```
|
||||
agentik standalone listening on http://localhost:8080
|
||||
GET /health
|
||||
POST /agentik/conversations -> 201
|
||||
GET /agentik/conversations/{id}/events -> SSE
|
||||
storage: /var/lib/agentik/state.db
|
||||
llm: OPENAI gpt-4o-mini @ https://api.openai.com/v1
|
||||
mcp: 4 tools from 2 servers
|
||||
skills: 3 loaded from /etc/agentik/skills
|
||||
soul: /etc/agentik/SOUL.md (842 chars)
|
||||
memory: /var/lib/agentik/memory (md-backend)
|
||||
compaction: enabled, threshold=0.8, window=128000 tokens
|
||||
curator: enabled (interval=1d, maxAge=90d)
|
||||
reflection: enabled (interval=10, topK=3)
|
||||
```
|
||||
|
||||
## Остановка
|
||||
|
||||
`Ctrl-C` → срабатывает shutdown hook: агент, MCP-серверы, SQLite-стора и
|
||||
LLM-клиент закрываются корректно (SQLite фиксирует WAL, MCP-процессы
|
||||
получают SIGTERM).
|
||||
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
|
||||
| `AGENTIK_TOKEN` | (пусто) | Bearer-токен для HTTP-фасада `/agentik`. Пусто — авторизация выключена |
|
||||
| `AGENTIK_A2A_TOKEN` | (пусто) | Bearer-токен для A2A-фасада `/a2a`. Пусто — авторизация выключена (независим от `AGENTIK_TOKEN`) |
|
||||
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
|
||||
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
|
||||
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
|
||||
| `AGENTIK_LLM_API_URL` | `http://localhost:8001/v1` | Endpoint для OpenAI-compatible |
|
||||
| `AGENTIK_LLM_API_KEY` | `no-key-needed` | Auth header |
|
||||
| `AGENTIK_LLM_CONTEXT_TOKENS` | `115000` | Сколько токенов остаётся модели |
|
||||
| `AGENTIK_GOOGLE_MODEL_PATH` | `/root/gemma-4-E2B-it.litertlm` | Путь к `.litertlm` файлу |
|
||||
| `AGENTIK_GOOGLE_MODEL_URL` | `https://static.binom.pw/models/gemma-4-E2B-it.litertlm` | Откуда скачивать |
|
||||
| `AGENTIK_GOOGLE_MODEL_SHA256_URL` | — | Если задан — verify по SHA-256 |
|
||||
| `AGENTIK_AUTO_DOWNLOAD_MODEL` | `0` | `1` = скачать модель если её нет |
|
||||
| `AGENTIK_MEMORY_BACKEND` | `vector` | `md`, `vector` или `off` |
|
||||
| `AGENTIK_EMBEDDING_BACKEND` | `http` | `http` или `siglip` (только для `vector`) |
|
||||
| `AGENTIK_EMBEDDING_API_URL` | `http://localhost:8001/v1` | Endpoint для эмбеддингов |
|
||||
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Имя embedding-модели |
|
||||
| `AGENTIK_SOUL_PATH` | `~/.agentik/SOUL.md` | Путь к SOUL.md |
|
||||
| `AGENTIK_SKILLS_DIR` | `~/.agentik/skills/` | Каталог SKILL.md |
|
||||
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory/` | Каталог для md-памяти |
|
||||
| `AGENTIK_TOOLSETS_DEFAULT` | `memory,skills,files,web` | Включённые тулы |
|
||||
| `AGENTIK_DEBUG` | `0` | `1` = verbose logging |
|
||||
|
||||
Значения читаются через `AgentikConfig.fromEnv()` в `:standalone/.../Main.kt`.
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
| Метод | Путь | Transport | Описание |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
|
||||
| `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
|
||||
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
|
||||
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
|
||||
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
|
||||
| `GET` | `/agentik/conversations` | :proto | Список диалогов |
|
||||
| `GET` | `/agentik/conversations/:id` | :proto | Snapshot |
|
||||
| `GET` | `/agentik/conversations/:id/messages` | :proto | История |
|
||||
| `POST` | `/agentik/conversations/:id/send` | :proto | Send (SSE) |
|
||||
| `GET` | `/agentik/conversations/:id/events` | :proto | Live-events (SSE) |
|
||||
| `POST` | `/agentik/conversations/:id/interrupt` | :proto | Прервать |
|
||||
| `POST` | `/agentik/conversations/:id/rename` | :proto | Переименовать |
|
||||
| `DELETE` | `/agentik/conversations/:id` | :proto | Удалить |
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
./gradlew :standalone:jvmTest
|
||||
```
|
||||
./gradlew :standalone:jvmTest # unit-тесты
|
||||
./gradlew :standalone:integrationTest # integration (Testcontainers)
|
||||
./gradlew :standalone:shadowJar # → build/libs/agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
1. **vLLM не поддерживает cancel-inference** (`interrupt()` только
|
||||
закрывает client SSE-socket; бэкенд всё равно генерирует до конца).
|
||||
2. **A2A JSON discriminator — `"kind"`** (text/file/data),
|
||||
а не `"type"`. См. `A2aJson` в `:standalone`.
|
||||
3. **SSE в не-TTY ssh закрывается на default Ktor timeout**.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Production-ready. Все KMP-модули проекта интегрированы. Полный
|
||||
manual-test checklist смотрите в [`MANUAL-TESTS.md`](../../MANUAL-TESTS.md)
|
||||
или `MANUAL-TESTS.md` в корне.
|
||||
|
||||
## Где скачать
|
||||
|
||||
- **Source**: `git clone https://git.binom.pw/subochev/agentik`
|
||||
- **Fatjar**: Gitea CI artifacts (через `.gitea/workflows/release.yml`
|
||||
на tag `v*`) или собирается через `./gradlew :standalone:shadowJar`.
|
||||
|
||||
## Версии
|
||||
|
||||
Все `gradle/libs.versions.toml`. Поднять версию → release через
|
||||
`git tag v0.2.0 && git push --tags` → CI собирает все KMP-таргеты
|
||||
публикует артефакты.
|
||||
|
||||
@@ -12,6 +12,13 @@ plugins {
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
|
||||
// зависимость — нужно для CI runner'а (text-embedding-kmp ещё не опубликован
|
||||
// в caffeine). Подробнее см. memory-vector/build.gradle.kts.
|
||||
val skipVectorMemory: Boolean =
|
||||
(project.findProperty("skipVectorMemory") == "true") ||
|
||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
@@ -46,10 +53,18 @@ kotlin {
|
||||
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
|
||||
implementation(project(":memory-api"))
|
||||
implementation(project(":memory-md"))
|
||||
if (!skipVectorMemory) {
|
||||
implementation(project(":memory-vector"))
|
||||
}
|
||||
implementation(project(":storage-core"))
|
||||
implementation(project(":storage-sqlite"))
|
||||
implementation(project(":agent-toolsets"))
|
||||
// Generic LLM-side tools (LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor, парсеры/промпты). Вынесены из :standalone.
|
||||
implementation(project(":llm-tools"))
|
||||
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
||||
// Вынесен из :standalone — generic мост между MCP-SDK и LiteTool.
|
||||
implementation(project(":mcp-bridge"))
|
||||
|
||||
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||||
runtimeOnly(libs.litert.google)
|
||||
|
||||
@@ -11,8 +11,8 @@ import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.standalone.agent.ChatConversation
|
||||
import pw.binom.agentik.standalone.agent.LlmReflector
|
||||
import pw.binom.agentik.standalone.agent.SkillMiner
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.llm.tools.SkillMiner
|
||||
import pw.binom.agentik.standalone.agent.memory.Curator
|
||||
import pw.binom.agentik.storage.StorageBundle
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package pw.binom.agentik.standalone
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
|
||||
|
||||
@@ -20,15 +21,17 @@ import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
|
||||
import pw.binom.agentik.server.agentikAgent
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
||||
import pw.binom.agentik.standalone.agent.LlmReflector
|
||||
import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
|
||||
import pw.binom.agentik.standalone.config.AgentikConfig
|
||||
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
||||
import pw.binom.agentik.llm.tools.LiteLlmContextCompactor
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
||||
import pw.binom.agentik.standalone.config.AppConfig
|
||||
import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.mcp.McpRegistry
|
||||
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||
import pw.binom.agentik.storage.sqlite.SqliteStores
|
||||
import java.io.File
|
||||
import pw.binom.agentik.llm.tools.SkillMiner
|
||||
/**
|
||||
* standalone-контейнер agentik:
|
||||
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
|
||||
@@ -46,26 +49,163 @@ import java.io.File
|
||||
* GET /a2a/.well-known/agent-card.json -> AgentCard
|
||||
* GET /health -> "ok"
|
||||
*
|
||||
* Вся конфигурация — [AgentikConfig.fromEnv] (см. [AgentikConfig]). Источники:
|
||||
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
|
||||
* - AGENTIK_PORT / AGENTIK_DB_PATH
|
||||
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
|
||||
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
|
||||
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
|
||||
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
|
||||
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
|
||||
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
|
||||
*/
|
||||
private val log = KotlinLogging.logger {}
|
||||
fun main() {
|
||||
val config = AgentikConfig.fromEnv()
|
||||
fun main(args: Array<String>) {
|
||||
if (args.isNotEmpty()) {
|
||||
when (args[0]) {
|
||||
"pull-model" -> {
|
||||
runPullModel(args.drop(1))
|
||||
return
|
||||
}
|
||||
"--help", "-h", "help" -> {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
else -> {
|
||||
System.err.println("Unknown subcommand: ${args[0]}")
|
||||
printHelp()
|
||||
kotlin.system.exitProcess(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
runServer()
|
||||
}
|
||||
|
||||
private fun printHelp() {
|
||||
println("""
|
||||
agentik standalone — usage:
|
||||
java -jar agentik.jar Start HTTP server (AGUI + A2A + :proto)
|
||||
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcommand `pull-model`: скачивает LiteRT-LM-модель по конфигу.
|
||||
*
|
||||
* Конфиг читается из тех же env, что и server: `AGENTIK_LLM_BACKEND=google`
|
||||
* (если не google — exit 2), `AGENTIK_GOOGLE_MODEL_PATH` (куда), и опциональный
|
||||
* `AGENTIK_GOOGLE_MODEL_URL` (откуда; дефолт — Gemma-4-E2B-it.litertlm с
|
||||
* static.binom.pw).
|
||||
*
|
||||
* SHA-256 проверяется если задан `AGENTIK_GOOGLE_MODEL_SHA256_URL`.
|
||||
*
|
||||
* Если файл по PATH уже есть и совпадает по размеру с HEAD — no-op (exit 0).
|
||||
*/
|
||||
private fun runPullModel(args: List<String>) {
|
||||
val config = AppConfig.fromEnv()
|
||||
val google = config.llm.google
|
||||
?: error("pull-model: требуется AGENTIK_LLM_BACKEND=google (сейчас ${config.llm.backend})")
|
||||
|
||||
val url = System.getenv("AGENTIK_GOOGLE_MODEL_URL")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ModelDownloader.DEFAULT_GEMMA_URL
|
||||
val sha256Url = System.getenv("AGENTIK_GOOGLE_MODEL_SHA256_URL")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
println("pull-model: downloading from $url")
|
||||
println("pull-model: saving to ${google.modelPath}")
|
||||
if (sha256Url != null) println("pull-model: SHA-256 verification enabled ($sha256Url)")
|
||||
|
||||
val downloader = ModelDownloader()
|
||||
val result = runBlocking {
|
||||
downloader.download(
|
||||
url = url,
|
||||
destPath = google.modelPath,
|
||||
sha256Url = sha256Url,
|
||||
progress = { downloaded, total ->
|
||||
val pct = if (total > 0) (downloaded * 100.0 / total).toInt() else -1
|
||||
val human = if (total > 0) {
|
||||
"${formatBytes(downloaded)} / ${formatBytes(total)} ($pct%)"
|
||||
} else {
|
||||
formatBytes(downloaded)
|
||||
}
|
||||
print("\r $human ")
|
||||
},
|
||||
)
|
||||
}
|
||||
println()
|
||||
|
||||
if (result.bytes == 0L) {
|
||||
println("pull-model: already present (${formatBytes(result.total)}), nothing to do")
|
||||
} else if (result.resumedFrom > 0) {
|
||||
println("pull-model: resumed from ${formatBytes(result.resumedFrom)}, added ${formatBytes(result.bytes - result.resumedFrom)} in ${result.duration}")
|
||||
} else {
|
||||
println("pull-model: downloaded ${formatBytes(result.bytes)} in ${result.duration}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(b: Long): String = when {
|
||||
b < 1024 -> "$b B"
|
||||
b < 1024L * 1024 -> "%.1f KB".format(b / 1024.0)
|
||||
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b / 1024.0 / 1024.0)
|
||||
else -> "%.2f GB".format(b / 1024.0 / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
private fun runServer() {
|
||||
val config = AppConfig.fromEnv()
|
||||
|
||||
// Перед созданием LLM: если backend=google и файл по AGENTIK_GOOGLE_MODEL_PATH
|
||||
// отсутствует — качаем автоматически (только при AGENTIK_AUTO_DOWNLOAD_MODEL=1),
|
||||
// иначе exit с понятной ошибкой.
|
||||
if (config.llm.backend == LlmBackend.GOOGLE) {
|
||||
val google = config.llm.google!!
|
||||
val modelFile = File(google.modelPath)
|
||||
if (!modelFile.exists()) {
|
||||
val url = System.getenv("AGENTIK_GOOGLE_MODEL_URL")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: ModelDownloader.DEFAULT_GEMMA_URL
|
||||
val sha256Url = System.getenv("AGENTIK_GOOGLE_MODEL_SHA256_URL")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val autoDownload = System.getenv("AGENTIK_AUTO_DOWNLOAD_MODEL") == "1"
|
||||
if (autoDownload) {
|
||||
log.warn { "auto-download: $url -> ${google.modelPath}" }
|
||||
val dl = ModelDownloader()
|
||||
val result = runBlocking {
|
||||
dl.download(
|
||||
url = url,
|
||||
destPath = google.modelPath,
|
||||
sha256Url = sha256Url,
|
||||
progress = { d, t ->
|
||||
val pct = if (t > 0) (d * 100.0 / t).toInt() else -1
|
||||
if (t > 0) log.info { "auto-download: $pct% (${formatBytes(d)}/${formatBytes(t)})" }
|
||||
},
|
||||
)
|
||||
}
|
||||
log.info { "auto-download: done in ${result.duration}" }
|
||||
} else {
|
||||
System.err.println(
|
||||
"""
|
||||
|LiteRT-LM model file not found at: ${google.modelPath}
|
||||
|
|
||||
|Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
|
||||
|Чтобы скачать руками:
|
||||
| java -jar agentik.jar pull-model
|
||||
|(URL по умолчанию: $url)
|
||||
""".trimMargin(),
|
||||
)
|
||||
kotlin.system.exitProcess(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val llm = config.llm.createLlm()
|
||||
val storage = SqliteStores.open(dbPath = config.dbPath).asBundle()
|
||||
val storage = SqliteStores.open(dbPath = config.agent.dbPath).asBundle()
|
||||
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
||||
|
||||
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
|
||||
// DiskSkillStore для self-improvement (`skill_save`/`skill_delete`).
|
||||
// Один и тот же файл-каталог используется и для чтения (read_skill),
|
||||
// и для записи — никаких рассинхронов.
|
||||
val skillStore: pw.binom.agentik.skills.SkillStore? = config.skillsDir?.let { dir ->
|
||||
val skillStore: pw.binom.agentik.skills.SkillStore? = config.agent.skillsDir?.let { dir ->
|
||||
pw.binom.agentik.skills.DiskSkillStore(File(dir))
|
||||
}
|
||||
val skills = skillStore?.catalog ?: SkillCatalog.EMPTY
|
||||
@@ -73,18 +213,18 @@ fun main() {
|
||||
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
|
||||
// которые модель забыла сохранить через `skill_save`. Работает только когда
|
||||
// есть куда писать (skillStore) и интервал > 0.
|
||||
val skillMiner: pw.binom.agentik.standalone.agent.SkillMiner? =
|
||||
if (skillStore != null && config.skillMiningInterval > 0) {
|
||||
pw.binom.agentik.standalone.agent.SkillMiner(
|
||||
val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? =
|
||||
if (skillStore != null && config.skillMining.interval > 0) {
|
||||
pw.binom.agentik.llm.tools.SkillMiner(
|
||||
llm = llm,
|
||||
maxTurns = config.skillMiningMaxTurns,
|
||||
maxTurns = config.skillMining.maxTurns,
|
||||
)
|
||||
} else null
|
||||
|
||||
// SOUL.md — файл персоны. Если задан — читается как plain text/markdown,
|
||||
// вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0
|
||||
// (на старте агента это фатально: нечего показывать LLM).
|
||||
val soulBody = config.soulPath?.let { path ->
|
||||
val soulBody = config.agent.soulPath?.let { path ->
|
||||
val file = File(path)
|
||||
if (!file.exists() || !file.isFile) {
|
||||
log.warn { "SOUL file not found: $path" }
|
||||
@@ -98,8 +238,8 @@ fun main() {
|
||||
// - MD (дефолт) — Hermes-style §-файлы в AGENTIK_MEMORY_DIR (~/.agentik/memory)
|
||||
// - VECTOR — SQLite + JVector + LLM-эмбеддинги (тот же agentik.db для metadata)
|
||||
// - OFF — память выключена (memoryDir="off" или memoryBackend="off")
|
||||
val rawMemory = config.memoryDir
|
||||
val memorySystem: MemorySystem? = when (config.memoryBackend) {
|
||||
val rawMemory = config.memory.dir
|
||||
val memorySystem: MemorySystem? = when (config.memory.backend) {
|
||||
MemoryBackend.OFF -> {
|
||||
println(" memory: disabled")
|
||||
null
|
||||
@@ -116,8 +256,8 @@ fun main() {
|
||||
}
|
||||
}
|
||||
MemoryBackend.VECTOR -> {
|
||||
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embeddingBackend) {
|
||||
AgentikConfig.EmbeddingBackend.HTTP -> {
|
||||
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embedding.backend) {
|
||||
AppConfig.EmbeddingBackend.HTTP -> {
|
||||
val llm = config.llm
|
||||
// Берём базовый URL + API key у активного LLM-бэкенда.
|
||||
// Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings
|
||||
@@ -129,38 +269,38 @@ fun main() {
|
||||
HttpEmbeddingClient(
|
||||
apiUrl = oa.baseUrl.trimEnd('/'),
|
||||
apiKey = oa.apiKey,
|
||||
model = config.embeddingModel,
|
||||
dimension = config.embeddingDimension,
|
||||
model = config.embedding.model,
|
||||
dimension = config.embedding.dimension,
|
||||
)
|
||||
}
|
||||
AgentikConfig.EmbeddingBackend.SIGLIP -> {
|
||||
val modelPath = checkNotNull(config.embeddingModelPath) {
|
||||
AppConfig.EmbeddingBackend.SIGLIP -> {
|
||||
val modelPath = checkNotNull(config.embedding.modelPath) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
||||
}
|
||||
val tokenizerPath = checkNotNull(config.embeddingTokenizerPath) {
|
||||
val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
|
||||
}
|
||||
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||
}
|
||||
}
|
||||
VectorMemorySystem.open(
|
||||
dbPath = config.dbPath,
|
||||
dbPath = config.agent.dbPath,
|
||||
embedding = embedding,
|
||||
).also {
|
||||
val backendLabel = when (config.embeddingBackend) {
|
||||
AgentikConfig.EmbeddingBackend.HTTP ->
|
||||
"model=${config.embeddingModel}, dim=${config.embeddingDimension}"
|
||||
AgentikConfig.EmbeddingBackend.SIGLIP ->
|
||||
val backendLabel = when (config.embedding.backend) {
|
||||
AppConfig.EmbeddingBackend.HTTP ->
|
||||
"model=${config.embedding.model}, dim=${config.embedding.dimension}"
|
||||
AppConfig.EmbeddingBackend.SIGLIP ->
|
||||
"model=siglip2-base (on-device), dim=${embedding.dimension}"
|
||||
}
|
||||
println(" memory: db=${config.dbPath} (vector-backend, $backendLabel)")
|
||||
println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Контекстное окно модели (для compaction'а working memory).
|
||||
// Если null — compaction выключен. Резолвится один раз из LlmConfig/env.
|
||||
val contextWindow: Int? = config.llm.resolveContextWindow()
|
||||
val contextWindow: Int? = config.llm.contextWindow
|
||||
val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
|
||||
|
||||
// Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена.
|
||||
@@ -175,11 +315,11 @@ fun main() {
|
||||
|
||||
// Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm)
|
||||
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
|
||||
val reflector: pw.binom.agentik.standalone.agent.LlmReflector? =
|
||||
if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null
|
||||
val reflector: pw.binom.agentik.llm.tools.LlmReflector? =
|
||||
if (config.reflection.interval > 0) LlmReflector(llm = llm) else null
|
||||
val recentReflections: List<pw.binom.agentik.storage.Reflection> =
|
||||
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking {
|
||||
storage.reflectionStore.listRecent(config.reflectionTopK)
|
||||
if (config.reflection.topK > 0) kotlinx.coroutines.runBlocking {
|
||||
storage.reflectionStore.listRecent(config.reflection.topK)
|
||||
} else emptyList()
|
||||
|
||||
val agent = ChatAgent(
|
||||
@@ -195,13 +335,11 @@ fun main() {
|
||||
memoryReviewer = memoryReviewer,
|
||||
soulBody = soulBody,
|
||||
contextWindow = contextWindow,
|
||||
compressionThreshold = config.compressionThreshold,
|
||||
compressionThreshold = config.memory.compressionThreshold,
|
||||
contextCompactor = contextCompactor,
|
||||
recentReflections = recentReflections,
|
||||
reflector = reflector,
|
||||
reflectionInterval = config.reflectionInterval,
|
||||
skillMiner = skillMiner,
|
||||
skillMiningInterval = config.skillMiningInterval,
|
||||
)
|
||||
|
||||
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
||||
@@ -214,12 +352,17 @@ fun main() {
|
||||
c
|
||||
} else null
|
||||
|
||||
val server = embeddedServer(CIO, port = config.port) {
|
||||
val server = embeddedServer(CIO, port = config.agent.port) {
|
||||
routing {
|
||||
get("/health") { call.respondText("ok") }
|
||||
agentikAgent(agent, path = "/agentik")
|
||||
a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a")
|
||||
if (config.debugEndpoints) {
|
||||
agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
|
||||
a2aAgent(
|
||||
agentName = "agentik",
|
||||
handler = A2aBridge(agent),
|
||||
path = "/a2a",
|
||||
token = config.agent.a2aToken,
|
||||
)
|
||||
if (config.debug.endpoints) {
|
||||
debugRoutes(
|
||||
agent = agent,
|
||||
storage = storage,
|
||||
@@ -231,20 +374,20 @@ fun main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
println("agentik standalone listening on http://localhost:${config.port}")
|
||||
println("agentik standalone listening on http://localhost:${config.agent.port}")
|
||||
println(" GET /health")
|
||||
println(" POST /agentik/conversations -> 201")
|
||||
println(" GET /agentik/conversations/{id}/events -> SSE")
|
||||
println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)")
|
||||
println(" GET /a2a/.well-known/agent-card.json -> AgentCard")
|
||||
println(" storage: ${config.dbPath}")
|
||||
println(" storage: ${config.agent.dbPath}")
|
||||
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
|
||||
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
|
||||
println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}")
|
||||
if (config.soulPath != null) println(" soul: ${config.soulPath} (${soulBody?.length ?: 0} chars)")
|
||||
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memoryBackend.name.lowercase()}-backend"}")
|
||||
println(" skills: ${skills.size} loaded${config.agent.skillsDir?.let { " from $it" } ?: ""}")
|
||||
if (config.agent.soulPath != null) println(" soul: ${config.agent.soulPath} (${soulBody?.length ?: 0} chars)")
|
||||
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}")
|
||||
if (contextWindow != null) {
|
||||
println(" compaction: enabled, threshold=${config.compressionThreshold}, window=$contextWindow tokens")
|
||||
println(" compaction: enabled, threshold=${config.memory.compressionThreshold}, window=$contextWindow tokens")
|
||||
} else {
|
||||
println(" compaction: disabled (OPENAI_CONTEXT_WINDOW not set)")
|
||||
}
|
||||
@@ -252,9 +395,9 @@ fun main() {
|
||||
println(" curator: enabled (interval=${pw.binom.agentik.standalone.agent.memory.Curator.DEFAULT_INTERVAL}, maxAge=${pw.binom.agentik.standalone.agent.memory.Curator.DEFAULT_MAX_AGE})")
|
||||
}
|
||||
if (skillMiner != null) {
|
||||
println(" skill-mining: enabled (interval=${config.skillMiningInterval} turns, maxTurns=${config.skillMiningMaxTurns})")
|
||||
println(" skill-mining: enabled (interval=${config.skillMining.interval} turns, maxTurns=${config.skillMining.maxTurns})")
|
||||
}
|
||||
if (config.debugEndpoints) {
|
||||
if (config.debug.endpoints) {
|
||||
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
|
||||
}
|
||||
// Token stats по существующим диалогам (агрегат на старте — каждая запись
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* Internal event bus для background work — separate from [ConversationEvents]
|
||||
* (который это SSE-event stream для клиента).
|
||||
*
|
||||
* Background work fires на **structural events**, не на interval-polling:
|
||||
* - **Review** — триггерится в CompactionCoordinator ПРЯМО ПЕРЕД удалением ходов
|
||||
* из working memory (last chance вытащить факты). Это уже было сделано
|
||||
* через `reviewer.reviewPreCompaction()` — оставляем как есть.
|
||||
* - **Reflection** — на `ConversationLifecycleEvent.Closing` (финальная
|
||||
* рефлексия перед закрытием) ИЛИ накопление N tool-failures в окне
|
||||
* (что-то идёт не так).
|
||||
* - **Skill mining** — на `CompactionEvent.Triggered` если `turnsToDelete > N`
|
||||
* (есть контент для минера) ИЛИ на `ConversationLifecycleEvent.Closing`.
|
||||
*
|
||||
* Subscribers (BackgroundScheduler) решают, что делать. НЕ текстовая
|
||||
* инспекция, НЕ regex — только структурные события с явным семантическим
|
||||
* смыслом. См. STANDALONE-REVIEW раздел "event-driven background".
|
||||
*/
|
||||
|
||||
/** Эмитится из [ToolDispatcher] после каждого `runToolAndPersist` (success/failure). */
|
||||
sealed interface ToolCallEvent {
|
||||
val toolName: String
|
||||
|
||||
data class Succeeded(
|
||||
override val toolName: String,
|
||||
val durationMs: Long,
|
||||
) : ToolCallEvent
|
||||
|
||||
data class Failed(
|
||||
override val toolName: String,
|
||||
val error: String,
|
||||
) : ToolCallEvent
|
||||
}
|
||||
|
||||
/** Эмитится из [CompactionCoordinator] ПЕРЕД `workingMemory.compact(...)`. */
|
||||
sealed interface CompactionEvent {
|
||||
/**
|
||||
* Compaction сейчас удалит N ходов из working memory. Background work
|
||||
* имеет последний шанс вытащить оттуда данные.
|
||||
*/
|
||||
data class Triggered(
|
||||
val turnsToDelete: Int,
|
||||
val conversationId: String,
|
||||
) : CompactionEvent
|
||||
}
|
||||
|
||||
/** Эмитится из `ConversationLoop.close()` сразу ПЕРЕД `agentScope.cancel()`. */
|
||||
sealed interface ConversationLifecycleEvent {
|
||||
data class Closing(val conversationId: String) : ConversationLifecycleEvent
|
||||
}
|
||||
|
||||
internal class BackgroundEventBus {
|
||||
private val _toolCallEvents = MutableSharedFlow<ToolCallEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 256,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val toolCallEvents: SharedFlow<ToolCallEvent> get() = _toolCallEvents.asSharedFlow()
|
||||
|
||||
private val _compactionEvents = MutableSharedFlow<CompactionEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 16,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val compactionEvents: SharedFlow<CompactionEvent> get() = _compactionEvents.asSharedFlow()
|
||||
|
||||
private val _lifecycleEvents = MutableSharedFlow<ConversationLifecycleEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 16,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val lifecycleEvents: SharedFlow<ConversationLifecycleEvent> get() = _lifecycleEvents.asSharedFlow()
|
||||
|
||||
fun tryEmit(event: ToolCallEvent): Boolean = _toolCallEvents.tryEmit(event)
|
||||
fun tryEmit(event: CompactionEvent): Boolean = _compactionEvents.tryEmit(event)
|
||||
fun tryEmit(event: ConversationLifecycleEvent): Boolean = _lifecycleEvents.tryEmit(event)
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
import pw.binom.agentik.storage.Content
|
||||
import pw.binom.agentik.storage.ReflectionStore
|
||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.llm.tools.SkillMiner
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Background work подписчик на [BackgroundEventBus]. Заменяет старую interval-based
|
||||
* логику (`maybeScheduleReview/Reflection/SkillMining` с `userTurnCount % N == 0`).
|
||||
*
|
||||
* Подписки:
|
||||
* - [ConversationLifecycleEvent.Closing] → финальный reflection + skill mining
|
||||
* перед закрытием conversation (last chance вытащить insights).
|
||||
* - [CompactionEvent.Triggered] → skill mining если `turnsToDelete > MIN_COMPACTION_FOR_MINING`.
|
||||
* Review уже сделан внутри CompactionCoordinator (`reviewPreCompaction`) — не дублируем.
|
||||
* - [ToolCallEvent.Failed] (накопительно) → reflection если 2+ фейлов в окне 60 сек
|
||||
* (что-то пошло не так — самоанализ полезен).
|
||||
*
|
||||
* НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные
|
||||
* события с явным семантическим смыслом.
|
||||
*/
|
||||
internal data class BackgroundConfig(
|
||||
val memoryReviewer: MemoryReviewer?,
|
||||
val memoryStore: MemoryStore?,
|
||||
val reflectionStore: ReflectionStore?,
|
||||
val reflector: LlmReflector?,
|
||||
val skillMiner: SkillMiner?,
|
||||
val skillMiningStore: SkillStore?,
|
||||
)
|
||||
|
||||
internal class BackgroundScheduler(
|
||||
private val state: ConversationState,
|
||||
private val workingMemory: WorkingMemoryStore,
|
||||
private val config: BackgroundConfig,
|
||||
private val backgroundEvents: BackgroundEventBus,
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val lastSkillMiningAt = AtomicLong(0)
|
||||
private val lastReflectionAt = AtomicLong(0)
|
||||
|
||||
/** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
|
||||
private val toolFailures = mutableListOf<Long>()
|
||||
private val toolFailuresLock = Any()
|
||||
|
||||
private var subscriptionJob: Job? = null
|
||||
|
||||
/** Запустить подписки. Вызывать один раз после конструктора. */
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (subscriptionJob?.isActive == true) return
|
||||
subscriptionJob = scope.launch {
|
||||
// Merge all three event flows into one subscription scope. Each onEach
|
||||
// returns Unit, so launchIn merges them as cold flows.
|
||||
merge(
|
||||
backgroundEvents.lifecycleEvents
|
||||
.filterIsInstance<ConversationLifecycleEvent.Closing>()
|
||||
.onEach { onClosing() },
|
||||
backgroundEvents.compactionEvents
|
||||
.filterIsInstance<CompactionEvent.Triggered>()
|
||||
.onEach { onCompaction(it) },
|
||||
backgroundEvents.toolCallEvents
|
||||
.filterIsInstance<ToolCallEvent.Failed>()
|
||||
.onEach { onToolFailure() },
|
||||
).collect {}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onClosing() {
|
||||
if (state.isTemporal) return
|
||||
val convId = state.id
|
||||
runFinalReflection(convId)
|
||||
runFinalSkillMining(convId)
|
||||
}
|
||||
|
||||
private fun runFinalReflection(convId: String) {
|
||||
val reflector = config.reflector ?: return
|
||||
val store = config.reflectionStore ?: return
|
||||
state.agentScope.launch {
|
||||
try {
|
||||
val turns = recentTurns(reflector.maxTurns)
|
||||
if (turns.isEmpty()) return@launch
|
||||
val reflection = reflector.reflect(turns) ?: return@launch
|
||||
runCatching { store.insert(reflection.copy(conversationId = convId)) }
|
||||
.onFailure { log.warn(it) { "final reflection insert failed: ${it.message}" } }
|
||||
log.info { "final reflection on close: conv=$convId score=${reflection.score}/5" }
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "final reflection failed for $convId: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runFinalSkillMining(convId: String) {
|
||||
val miner = config.skillMiner ?: return
|
||||
val store = config.skillMiningStore ?: return
|
||||
state.agentScope.launch {
|
||||
try {
|
||||
val turns = recentTurns(miner.maxTurns)
|
||||
if (turns.isEmpty()) return@launch
|
||||
val mined = miner.mine(turns, store.catalog.skills)
|
||||
for (s in mined) {
|
||||
runCatching { store.upsert(s) }
|
||||
.onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
|
||||
}
|
||||
log.info { "final skill mining on close: conv=$convId turns=${turns.size} existing=${store.catalog.skills.size} mined=${mined.size}" }
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "final skill mining failed for $convId: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCompaction(event: CompactionEvent.Triggered) {
|
||||
if (state.isTemporal) return
|
||||
if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return
|
||||
val miner = config.skillMiner ?: return
|
||||
val store = config.skillMiningStore ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
// Debounce: не чаще раза в минуту
|
||||
if (now - lastSkillMiningAt.get() < MINING_DEBOUNCE_MS) return
|
||||
lastSkillMiningAt.set(now)
|
||||
val convId = event.conversationId
|
||||
state.agentScope.launch {
|
||||
try {
|
||||
val turns = recentTurns(miner.maxTurns)
|
||||
if (turns.isEmpty()) return@launch
|
||||
val mined = miner.mine(turns, store.catalog.skills)
|
||||
for (s in mined) {
|
||||
runCatching { store.upsert(s) }
|
||||
.onFailure { log.warn(it) { "compaction skill mining upsert '${s.name}' failed: ${it.message}" } }
|
||||
}
|
||||
log.info { "compaction skill mining: conv=$convId turnsToDelete=${event.turnsToDelete} mined=${mined.size}" }
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "compaction skill mining failed for $convId: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onToolFailure() {
|
||||
val reflector = config.reflector ?: return
|
||||
if (state.isTemporal) return
|
||||
val store = config.reflectionStore ?: return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val shouldReflect = synchronized(toolFailuresLock) {
|
||||
toolFailures.add(now)
|
||||
// Trim old failures outside the window
|
||||
val cutoff = now - FAILURE_WINDOW_MS
|
||||
toolFailures.removeAll { it < cutoff }
|
||||
toolFailures.size >= FAILURE_THRESHOLD
|
||||
}
|
||||
if (!shouldReflect) return
|
||||
|
||||
// Debounce reflection globally (не чаще раза в 5 мин)
|
||||
if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) {
|
||||
log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" }
|
||||
return
|
||||
}
|
||||
lastReflectionAt.set(now)
|
||||
|
||||
// Clear failure window — fresh accounting period
|
||||
synchronized(toolFailuresLock) { toolFailures.clear() }
|
||||
|
||||
val convId = state.id
|
||||
state.agentScope.launch {
|
||||
try {
|
||||
val turns = recentTurns(reflector.maxTurns)
|
||||
if (turns.isEmpty()) return@launch
|
||||
val reflection = reflector.reflect(turns) ?: return@launch
|
||||
runCatching { store.insert(reflection.copy(conversationId = convId)) }
|
||||
.onFailure { log.warn(it) { "reflection after failures insert failed: ${it.message}" } }
|
||||
log.info { "reflection triggered by tool failures: conv=$convId score=${reflection.score}/5" }
|
||||
} catch (e: Throwable) {
|
||||
log.warn(e) { "reflection after tool failures failed for $convId: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recentTurns(maxTurns: Int): List<ConversationTurn> {
|
||||
val rows = workingMemory.list(state.id)
|
||||
val pairs = mutableListOf<ConversationTurn>()
|
||||
var pendingUser: String? = null
|
||||
for (row in rows) {
|
||||
when (val e = row.entry) {
|
||||
is WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
||||
is WorkingMemoryEntry.Assistant -> {
|
||||
val user = pendingUser ?: ""
|
||||
pendingUser = null
|
||||
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return pairs.takeLast(maxTurns)
|
||||
}
|
||||
|
||||
private fun List<Content>.text(): String =
|
||||
filterIsInstance<Content.Text>().joinToString("\n") { it.body }
|
||||
|
||||
companion object {
|
||||
/** Минимум ходов, удаляемых compaction'ом, чтобы trigger'ить skill mining. */
|
||||
private const val MIN_COMPACTION_FOR_MINING = 10
|
||||
/** Дебаунс skill mining между запусками. */
|
||||
private const val MINING_DEBOUNCE_MS = 60_000L
|
||||
/** Debounce reflection между запусками (накопительный, не per-failure). */
|
||||
private const val REFLECTION_DEBOUNCE_MS = 300_000L
|
||||
/** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */
|
||||
private const val FAILURE_THRESHOLD = 2
|
||||
/** Окно для accumulation tool-failures. */
|
||||
private const val FAILURE_WINDOW_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||
import pw.binom.agentik.toolsets.ToolsetRegistry
|
||||
import pw.binom.litert.LiteLlm
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.llm.tools.SkillMiner
|
||||
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
|
||||
@@ -95,19 +100,11 @@ class ChatAgent(
|
||||
*/
|
||||
private val reflector: LlmReflector? = null,
|
||||
/**
|
||||
* Через сколько пользовательских ходов запускать рефлексию. `0` = выключено.
|
||||
*/
|
||||
private val reflectionInterval: Int = 0,
|
||||
/**
|
||||
* Фоновый минер скилов: каждые N ходов LLM смотрит последние ходы и
|
||||
* upsert-ит переиспользуемые скилы в [skillStore]. `null` = mining выключен.
|
||||
* Фоновый минер скилов: LLM-вызов, который запускается на compaction
|
||||
* (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен.
|
||||
* Сетка безопасности, если модель забыла вызвать `skill_save` сама.
|
||||
*/
|
||||
private val skillMiner: SkillMiner? = null,
|
||||
/**
|
||||
* Через сколько пользовательских ходов запускать skill mining. `0` = выключено.
|
||||
*/
|
||||
private val skillMiningInterval: Int = 0,
|
||||
/**
|
||||
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
|
||||
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
|
||||
@@ -116,6 +113,16 @@ class ChatAgent(
|
||||
private val toolsets: List<ToolsetContribution> = emptyList(),
|
||||
) : ProtoAgent, AutoCloseable {
|
||||
|
||||
/**
|
||||
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ
|
||||
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для
|
||||
* симуляции долгого tool-вызова, который можно прервать через interrupt().
|
||||
* В production этот API НЕ используется — тулы статичны через конструктор.
|
||||
*/
|
||||
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
|
||||
toolsByName[name] = NamedTool(name = name, tool = tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Реестр активных тулсетов — один на агента (per-agent state).
|
||||
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
|
||||
@@ -164,7 +171,7 @@ class ChatAgent(
|
||||
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
|
||||
}
|
||||
|
||||
private val toolsByName: Map<String, NamedTool> = allTools.associateBy { it.name }
|
||||
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
|
||||
|
||||
/**
|
||||
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
|
||||
@@ -212,11 +219,6 @@ class ChatAgent(
|
||||
if (!temp) {
|
||||
runBlocking {
|
||||
storage.conversationStore.upsert(rec)
|
||||
storage.workingMemoryStore.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.System(text = systemPrompt),
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
}
|
||||
val conv = ChatConversation(
|
||||
@@ -234,10 +236,8 @@ class ChatAgent(
|
||||
contextCompactor = contextCompactor,
|
||||
reflectionStore = storage.reflectionStore,
|
||||
reflector = reflector,
|
||||
reflectionInterval = reflectionInterval,
|
||||
skillMiner = skillMiner,
|
||||
skillMiningStore = skillStore,
|
||||
skillMiningInterval = skillMiningInterval,
|
||||
)
|
||||
runBlocking {
|
||||
liveLock.withLock { live[conv.id] = conv }
|
||||
@@ -285,10 +285,8 @@ class ChatAgent(
|
||||
contextCompactor = contextCompactor,
|
||||
reflectionStore = storage.reflectionStore,
|
||||
reflector = reflector,
|
||||
reflectionInterval = reflectionInterval,
|
||||
skillMiner = skillMiner,
|
||||
skillMiningStore = skillStore,
|
||||
skillMiningInterval = skillMiningInterval,
|
||||
)
|
||||
|
||||
override fun close() {
|
||||
|
||||
+9
-1094
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user