Compare commits
29 Commits
04276dec0e
...
1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,89 @@
|
|||||||
|
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
|
||||||
|
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
|
||||||
|
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
||||||
|
#
|
||||||
|
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||||
|
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||||
|
# release.yml для требуемых переменных.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-jvm:
|
||||||
|
name: JVM build + tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'adopt'
|
||||||
|
|
||||||
|
- name: Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
.gradle
|
||||||
|
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-agentik-
|
||||||
|
|
||||||
|
- name: Build + test (JVM only — самые быстрые таргеты)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew jvmTest \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
- name: Build :standalone shadowJar (smoke — запускаемый артефакт)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :standalone:shadowJar \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
test -f standalone/build/libs/standalone-*-all.jar \
|
||||||
|
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||||
|
|
||||||
|
- name: Build :agentik-cli shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :agentik-cli:shadowJar \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
test -f agentik-cli/build/libs/agentik-cli-all.jar \
|
||||||
|
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-all.jar)"
|
||||||
|
|
||||||
|
- name: Build :agentik-tui shadowJar
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :agentik-tui:shadowJar \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
test -f agentik-tui/build/libs/agentik-tui-all.jar \
|
||||||
|
&& echo "shadowJar OK: $(du -h agentik-tui/build/libs/agentik-tui-all.jar)"
|
||||||
|
|
||||||
|
- name: Upload shadowJars
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: agentik-jars
|
||||||
|
path: |
|
||||||
|
standalone/build/libs/standalone-all.jar
|
||||||
|
agentik-cli/build/libs/agentik-cli-all.jar
|
||||||
|
agentik-tui/build/libs/agentik-tui-all.jar
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -1,36 +1,31 @@
|
|||||||
# Триггерится при публикации релиза в Gitea. Делает две вещи:
|
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||||
# 1. publish-libraries — публикует все KMP-библиотеки (jvm + все нативные таргеты)
|
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||||
# в домашний Nexus-репозиторий "caffeine" через subochev/devops/publish action.
|
#
|
||||||
# Переменные BINOM_REPO_URL / BINOM_REPO_USER / BINOM_REPO_PASSWORD задаются
|
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli, :agentik-tui)
|
||||||
# в Gitea Action Variables для репозитория (Settings → Actions → Variables).
|
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||||
# 2. build-standalone — собирает :standalone fatjar (shadowJar) и прикрепляет
|
# выполняется локально из исходников (или руками через `./gradlew
|
||||||
# standalone-<version>-all.jar к release как downloadable asset.
|
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||||
|
# отдельно от этого workflow.
|
||||||
|
#
|
||||||
|
# Требуемые Gitea Action Variables:
|
||||||
|
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
|
||||||
|
# Требуемые Gitea Action Secrets:
|
||||||
|
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
||||||
name: release
|
name: release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-libraries:
|
publish-libraries:
|
||||||
name: Publish KMP libraries → caffeine Nexus
|
name: Publish KMP libraries → caffeine Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
timeout-minutes: 120
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# agentik не использует Android-target ни в одном модуле (все KMP-таргеты
|
|
||||||
# JVM + native), поэтому Android SDK шаг из litert-kmp тут не нужен.
|
|
||||||
|
|
||||||
- name: Publish libraries
|
|
||||||
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -41,22 +36,40 @@ jobs:
|
|||||||
java-version: '21'
|
java-version: '21'
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Build shadowJar
|
- name: Gradle cache
|
||||||
shell: bash
|
uses: actions/cache@v4
|
||||||
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:
|
with:
|
||||||
files: |
|
path: |
|
||||||
standalone/build/libs/standalone-*-all.jar
|
~/.gradle/caches
|
||||||
standalone/build/libs/standalone-*-sources.jar
|
~/.gradle/wrapper
|
||||||
fail_on_unmatched_files: false
|
.gradle
|
||||||
generate_release_notes: false
|
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-agentik-
|
||||||
|
|
||||||
|
- name: Publish libraries (all KMP targets, all modules)
|
||||||
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
|
||||||
|
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
|
||||||
|
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
|
||||||
|
run: |
|
||||||
|
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
|
||||||
|
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
|
||||||
|
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
|
||||||
|
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
|
||||||
|
#
|
||||||
|
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
|
||||||
|
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
|
||||||
|
# хардкодит версию — берёт её из тега каждый раз.
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "Publishing version: ${VERSION}"
|
||||||
|
./gradlew \
|
||||||
|
"-Pversion=${VERSION}" \
|
||||||
|
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
|
||||||
|
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
|
||||||
|
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
|
||||||
|
publish \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|||||||
+947
@@ -0,0 +1,947 @@
|
|||||||
|
# Manual Test Cases — agentik standalone
|
||||||
|
|
||||||
|
Практический чек-лист для проверки работающего `agentik standalone` HTTP-сервера.
|
||||||
|
Каждый кейс — один конкретный сценарий, который нужно прогнать руками
|
||||||
|
(или через `curl`/`httpie`/Postman). Если какой-то упал — это либо
|
||||||
|
регрессия, либо недонастройка рантайма.
|
||||||
|
|
||||||
|
Перед стартом: запусти агент (см. `run-agentik.sh` на удалённой машине
|
||||||
|
или `./gradlew :standalone:run` локально). Все примеры ниже — против
|
||||||
|
`http://127.0.0.1:8080`; для удалённой машины подставь свой хост.
|
||||||
|
|
||||||
|
Удобный сниппет для получения conversation ID в shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
echo "CID=$CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
Отправка user-сообщения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"..."}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтение истории:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Connectivity & health
|
||||||
|
|
||||||
|
### TC-1.1 — health endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i http://127.0.0.1:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `HTTP/1.1 200 OK`, тело `ok`.
|
||||||
|
|
||||||
|
### TC-1.2 — agent card (A2A)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:8080/a2a/.well-known/agent-card.json | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** валидный JSON с `name`, `version`, `capabilities`.
|
||||||
|
|
||||||
|
### TC-1.3 — log sanity check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -50 /root/agentik.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** есть строка `agentik standalone listening on http://localhost:8080`,
|
||||||
|
перечислены зарегистрированные маршруты, `llm: <backend> @ <url>` соответствует
|
||||||
|
твоему конфигу. **Нет** ERROR/Exception строк после старта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Conversation lifecycle
|
||||||
|
|
||||||
|
### TC-2.1 — create persistent conversation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `201`, тело `{"id":"conv-...","isTemporal":false,...}`.
|
||||||
|
|
||||||
|
### TC-2.2 — create temp conversation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `201`, `"isTemporal":true`. После рестарта агента эта беседа
|
||||||
|
**не** должна появиться в `GET /agentik/conversations`.
|
||||||
|
|
||||||
|
### TC-2.3 — list conversations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=20" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** массив объектов `ConversationSnapshot`. Отсортирован по
|
||||||
|
`updatedAt` desc.
|
||||||
|
|
||||||
|
### TC-2.4 — rename conversation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=<id-from-2.1>
|
||||||
|
curl -sS -X PATCH "http://127.0.0.1:8080/agentik/conversations/$CID" \
|
||||||
|
-H "Content-Type: application/json" -d '{"title":"Мой первый чат"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `200`, в ответе `"title":"Мой первый чат"`. Следующий `GET
|
||||||
|
/conversations/$CID` возвращает этот же title.
|
||||||
|
|
||||||
|
### TC-2.5 — delete conversation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X DELETE "http://127.0.0.1:8080/agentik/conversations/$CID" -i
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `204 No Content`. Повторный `GET /conversations/$CID` → `404`.
|
||||||
|
После этого в `GET /conversations` её быть не должно.
|
||||||
|
|
||||||
|
### TC-2.6 — get non-existent conversation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i http://127.0.0.1:8080/agentik/conversations/conv-nonexistent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `404`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Message sending
|
||||||
|
|
||||||
|
### TC-3.1 — simple Q&A
|
||||||
|
|
||||||
|
Создай беседу, пошли простой вопрос, прочитай историю.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Сколько будет 7*8? Одно число, без пояснений."}]'
|
||||||
|
sleep 6
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** массив из ≥ 2 сообщений:
|
||||||
|
- `[0].type == "user_message"`, body содержит "7*8"
|
||||||
|
- `[1].type == "assistant_message"`, text содержит "56"
|
||||||
|
|
||||||
|
### TC-3.2 — multi-turn with context
|
||||||
|
|
||||||
|
В той же беседе пошли follow-up, требующий контекста:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"А корень из того, что ты назвал?"}]'
|
||||||
|
sleep 6
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** 4+ сообщения, последний assistant упомянул что-то про число 56
|
||||||
|
или "предыдущий ответ".
|
||||||
|
|
||||||
|
### TC-3.3 — new-format request body
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"content":[{"type":"text","body":"С новым форматом тоже работает?"}]}'
|
||||||
|
sleep 6
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1]['type'], m[-1].get('content'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** новое `assistant_message` в ответ на новый формат запроса.
|
||||||
|
|
||||||
|
### TC-3.4 — empty / bad body
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" -d 'not json'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `400 Bad Request`, тело с пояснением `Invalid send payload`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SSE live events
|
||||||
|
|
||||||
|
> **Важно:** SSE — поток без replay. Подписываться нужно **до** `POST /messages`.
|
||||||
|
> Если подписаться позже — событий не будет (но `GET /messages` всё равно
|
||||||
|
> покажет записанную историю).
|
||||||
|
|
||||||
|
### TC-4.1 — subscribe-then-send pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=<existing-id>
|
||||||
|
# Subscribe в фоне, отправляем сообщение, ждём SSE
|
||||||
|
curl -sN --max-time 12 \
|
||||||
|
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||||
|
> /tmp/sse.out 2>&1 &
|
||||||
|
SSE_PID=$!
|
||||||
|
sleep 1
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Кратко: что такое REST?"}]'
|
||||||
|
wait $SSE_PID
|
||||||
|
cat /tmp/sse.out
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** файл содержит `data: {"type":"start_reasoning",...}`,
|
||||||
|
`data: {"type":"start_response",...,"responseType":"text"}`,
|
||||||
|
один или несколько `data: {"type":"append_text",...,"body":"..."}`,
|
||||||
|
`data: {"type":"end",...}`. Каждое `data:` через пустую строку.
|
||||||
|
|
||||||
|
### TC-4.2 — late subscribe (replay semantics)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=<existing-id>
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"..."}]'
|
||||||
|
sleep 5 # сообщение уже обработано
|
||||||
|
curl -sN --max-time 4 \
|
||||||
|
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** пустой ответ (события не реплеятся). Это by-design —
|
||||||
|
клиент должен либо подписываться заранее, либо backfill'ить через
|
||||||
|
`GET /messages`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Memory tools (long-term)
|
||||||
|
|
||||||
|
### TC-5.1 — save + recall в той же беседе
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В существующей беседе
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Запомни через memory_save: я работаю на удалёнке из Тбилиси. Категория user, content: работаю на удалёнке из Тбилиси."}]'
|
||||||
|
sleep 8
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Откуда я работаю? Одно предложение."}]'
|
||||||
|
sleep 8
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** ассистент ответил что-то содержащее "Тбилиси" (или явно
|
||||||
|
сказал "не знаю" — это тоже валидно, если в conversation memory пусто).
|
||||||
|
Проверить `audit log` (`messageStore`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /root/agentik.db "SELECT toolName, result FROM MessageRecord WHERE conversationId='$CID' AND kind='tool_result'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Должны быть строки с `toolName='memory_save'` или `toolName='memory_recall'`.
|
||||||
|
|
||||||
|
### TC-5.2 — memory persists across conversations
|
||||||
|
|
||||||
|
Создай новую беседу, спроси без подсказок:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEW_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Откуда я работаю? Напомни, если помнишь."}]'
|
||||||
|
sleep 8
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** ассистент упомянул "Тбилиси" (или "удалёнка") — это
|
||||||
|
значит long-term memory подгрузилась в новую беседу.
|
||||||
|
|
||||||
|
### TC-5.3 — invalid category → ошибка или автозамена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Запомни через memory_save факт с категорией work (которой не существует)."}]'
|
||||||
|
sleep 8
|
||||||
|
sqlite3 /root/agentik.db "SELECT toolArgs, result FROM MessageRecord WHERE kind='tool_call' AND conversationId='$CID' ORDER BY createdAt DESC LIMIT 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** модель либо вызвала `memory_recall` чтобы проверить
|
||||||
|
существующие категории, либо вызвала `memory_save` с корректной
|
||||||
|
категорией (`user`/`world`/`preference`). Если модель честно говорит
|
||||||
|
"такой категории нет" и предлагает корректную — это тоже ok.
|
||||||
|
|
||||||
|
### TC-5.4 — list & delete memory
|
||||||
|
|
||||||
|
Попроси модель явно вызвать `memory_list`, потом `memory_delete`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Покажи все мои memory-записи (memory_list)."}]'
|
||||||
|
sleep 8
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Удали самую старую запись (memory_delete)."}]'
|
||||||
|
sleep 8
|
||||||
|
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MemoryStore"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** число уменьшилось на 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Skills
|
||||||
|
|
||||||
|
### TC-6.1 — list + load skill
|
||||||
|
|
||||||
|
Если в `AGENTIK_SKILLS_DIR` есть файлы `SKILL.md` / `*.yaml`, в системном
|
||||||
|
промте должна появиться секция с этими навыками.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /root/skills/ # должен быть хотя бы один файл
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Какие skills ты знаешь? Покажи список (skill_list)."}]'
|
||||||
|
sleep 8
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Загрузи любой из них через skill_load и расскажи, что внутри."}]'
|
||||||
|
sleep 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `tool_call` для `skill_list`, потом `tool_call` для
|
||||||
|
`skill_load`. В audit log видны эти вызовы. Если папка пуста — секции
|
||||||
|
"Skills" в system prompt быть не должно.
|
||||||
|
|
||||||
|
### TC-6.2 — save new skill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Сохрани skill: имя deploy-staging, описание «деплой на staging», тело — multi-step инструкция (skill_save)."}]'
|
||||||
|
sleep 10
|
||||||
|
ls /root/skills/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** появился новый файл `deploy-staging.md` (или `.yaml`).
|
||||||
|
|
||||||
|
### TC-6.3 — restart → skill persists
|
||||||
|
|
||||||
|
Перезапусти агент:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.76.166 'pkill -9 -f agentik-0.1.0-all.jar; cd /root && nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown'
|
||||||
|
```
|
||||||
|
|
||||||
|
После старта пошли в новую беседу:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Есть ли у тебя skill deploy-staging?"}]'
|
||||||
|
sleep 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** модель упоминает skill (он подгружается на старте).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SOUL file
|
||||||
|
|
||||||
|
### TC-7.1 — SOUL.md подключается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'Ты — ворчливый капитан дальнего плавания. Отвечай кратко, с морскими метафорами.' > /root/SOUL.md
|
||||||
|
# Перезапустить агент
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Как дела?"}]'
|
||||||
|
sleep 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** ответ в стиле "капитана", с морскими словами. Если SOUL
|
||||||
|
нет — обычный нейтральный ассистент.
|
||||||
|
|
||||||
|
### TC-7.2 — SOUL можно менять на лету
|
||||||
|
|
||||||
|
Измени файл, перезапусти агент, спроси снова. **Должен** появиться новый
|
||||||
|
стиль. Без перезапуска изменения не подхватятся (SOUL читается на старте).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Interrupt
|
||||||
|
|
||||||
|
### TC-8.1 — interrupt mid-text generation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
# Запусти send в фоне
|
||||||
|
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Расскажи длинную историю про космос, минимум 500 слов."}]' >/dev/null) &
|
||||||
|
SEND_PID=$!
|
||||||
|
sleep 3 # дать LLM начать генерацию
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||||
|
wait $SEND_PID
|
||||||
|
sleep 3
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);
|
||||||
|
for x in m: print(x.get('type'), ':', json.dumps(x.get('content') or x.get('result'),ensure_ascii=False)[:80])"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:**
|
||||||
|
- `user_message` есть
|
||||||
|
- `assistant_message` есть, но содержит **короткий** текст (<300 символов)
|
||||||
|
— это частичный текст, который модель успела сгенерить до прерывания
|
||||||
|
- В audit log нет `tool_call`/`tool_result` (не успели)
|
||||||
|
- Следующий `send` в этой беседе работает (LiteConv пересоздан)
|
||||||
|
|
||||||
|
### TC-8.2 — interrupt mid-tool (best-effort)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Длинный tool можно заэмулировать через MCP с искусственной задержкой,
|
||||||
|
# либо просто проверять что interrupt не валит агента:
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||||
|
curl -sS http://127.0.0.1:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `health` = `ok` — агент не упал. Дальнейшие `send` работают.
|
||||||
|
|
||||||
|
### TC-8.3 — interrupt без активного turn'а
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `202`. Никаких ошибок. В audit log ничего нового не пишется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Persistence / restart-survival
|
||||||
|
|
||||||
|
### TC-9.1 — перезапуск не теряет беседы и память
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Создай беседу, пошли сообщение, дождись ответа
|
||||||
|
# 2. Запомни факт через memory_save
|
||||||
|
# 3. Перезапусти агент (см. TC-6.3)
|
||||||
|
# 4. GET /agentik/conversations — беседа должна быть в списке
|
||||||
|
# 5. GET /agentik/conversations/$CID/messages — история на месте
|
||||||
|
# 6. Новая беседа + вопрос про запомненный факт — модель помнит
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC-9.2 — temp conversation не переживает рестарт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создай temp беседу, пошли сообщение
|
||||||
|
TEMP_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":true}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$TEMP_CID/messages" \
|
||||||
|
-H "Content-Type: application/json" -d '[{"type":"text","body":"..."}]' >/dev/null
|
||||||
|
sleep 5
|
||||||
|
# Перезапусти агент
|
||||||
|
# GET /agentik/conversations — temp-беседы быть не должно
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=50" | grep "$TEMP_CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** grep ничего не находит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Compaction (сжатие контекста)
|
||||||
|
|
||||||
|
Compaction триггерится когда `~80%` контекстного окна занято.
|
||||||
|
|
||||||
|
### TC-10.1 — длинная беседа сжимается
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
# Отправь 30+ больших сообщений подряд (можно цикл)
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "[{\"type\":\"text\",\"body\":\"Расскажи подробно (минимум 200 слов) про тему номер $i: история, применение, ключевые факты.\"}]" >/dev/null
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM WorkingMemoryRow WHERE conversationId='$CID' AND entryKind='summary'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** есть хотя бы одна `summary`-запись. Также проверь
|
||||||
|
`/root/agentik.log` — должна появиться строка `compaction`.
|
||||||
|
|
||||||
|
### TC-10.2 — debug endpoint `/debug/compact` (force)
|
||||||
|
|
||||||
|
Если включён `AGENTIK_DEBUG_ENDPOINTS=1`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/debug/compact?conversationId=$CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `200`, тело с JSON-результатом compaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Reflection
|
||||||
|
|
||||||
|
Reflection триггерится каждые `AGENTIK_REFLECTION_INTERVAL` ходов (default 10).
|
||||||
|
|
||||||
|
### TC-11.1 — reflection создаёт записи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пошли 12+ ходов
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "[{\"type\":\"text\",\"body\":\"Тема $i: расскажи короткий факт.\"}]" >/dev/null
|
||||||
|
sleep 4
|
||||||
|
done
|
||||||
|
sleep 10 # дать фоновое задание завершиться
|
||||||
|
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM ReflectionStore"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** число > 0.
|
||||||
|
|
||||||
|
### TC-11.2 — debug endpoint `/debug/reflect`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/debug/reflect?conversationId=$CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `200`, JSON-результат. Reflection попадает в working memory
|
||||||
|
следующего turn'а.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Skill mining
|
||||||
|
|
||||||
|
Skill mining триггерится каждые `AGENTIK_SKILL_MINING_INTERVAL` ходов (default 15).
|
||||||
|
|
||||||
|
### TC-12.1 — авто-создание skill'а
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пошли 18+ ходов с повторяющимся паттерном
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "[{\"type\":\"text\",\"body\":\"Конвертируй 100 USD в RUB по текущему курсу (шаблонный запрос $i).\"}]" >/dev/null
|
||||||
|
sleep 4
|
||||||
|
done
|
||||||
|
sleep 15
|
||||||
|
ls -la /root/skills/
|
||||||
|
tail -20 /root/agentik.log | grep -i skill
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** возможно появился новый файл в skills/ (или mining
|
||||||
|
отказался из-за низкой уверенности — это тоже валидно, проверь лог).
|
||||||
|
|
||||||
|
### TC-12.2 — debug endpoint `/debug/skill-mine`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/debug/skill-mine?conversationId=$CID"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `200` с JSON-результатом майнинга.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Token accounting
|
||||||
|
|
||||||
|
### TC-13.1 — token counters в audit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /root/agentik.db "SELECT createdAt, input, output FROM TurnTokens WHERE conversationId='$CID' ORDER BY createdAt DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** строки с непустыми `input` и `output` (если backend
|
||||||
|
поддерживает `tokenCount()`).
|
||||||
|
|
||||||
|
### TC-13.2 — debug endpoint `/debug/tokens`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "http://127.0.0.1:8080/debug/tokens?conversationId=$CID" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** JSON с `input`, `output`, `total`, `window`,
|
||||||
|
`utilization` (доля использования контекстного окна).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Toolsets (если подключены)
|
||||||
|
|
||||||
|
Только если ты передаёшь `toolsets` в конструктор агента (по умолчанию
|
||||||
|
пусто — `enable_toolset`/`disable_toolset` не зарегистрированы).
|
||||||
|
|
||||||
|
### TC-14.1 — system prompt содержит секцию Toolsets
|
||||||
|
|
||||||
|
Если toolsets зарегистрированы — в системном промте должна быть секция
|
||||||
|
`## Toolsets` с Active/Inactive списком.
|
||||||
|
|
||||||
|
Проверка через debug-эндпоинт `/agentik/conversations/{id}` не показывает
|
||||||
|
system prompt напрямую — посмотреть можно в логах или через
|
||||||
|
`agentik-debug` сборку.
|
||||||
|
|
||||||
|
### TC-14.2 — enable/disable работает
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Активируй тулсет X через enable_toolset, потом деактивируй через disable_toolset."}]'
|
||||||
|
sleep 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** в audit log видны вызовы `enable_toolset` → ответ `"Toolset
|
||||||
|
'X' activated."`, потом `disable_toolset` → `"Toolset 'X' deactivated."`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. A2A протокол (опционально)
|
||||||
|
|
||||||
|
### TC-15.1 — message/send через A2A
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc":"2.0","id":"1","method":"message/send",
|
||||||
|
"params":{
|
||||||
|
"message":{"role":"user","parts":[{"kind":"text","text":"Скажи hi"}]},
|
||||||
|
"configuration":{"blocking":true}
|
||||||
|
}
|
||||||
|
}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** JSON-RPC ответ с `result.parts` содержащим текст "hi"
|
||||||
|
или похожим. `kind` = `text` (НЕ `type` — это важный discriminator для
|
||||||
|
A2A JSON).
|
||||||
|
|
||||||
|
### TC-15.2 — bad discriminator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc":"2.0","id":"2","method":"message/send",
|
||||||
|
"params":{
|
||||||
|
"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** `Invalid params` (или похожая ошибка) — A2A ждёт `kind`,
|
||||||
|
не `type`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Error paths
|
||||||
|
|
||||||
|
### TC-16.1 — LLM недоступен
|
||||||
|
|
||||||
|
Выключи vLLM (или закрой сеть — например через firewall). Пошли сообщение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"hi"}]'
|
||||||
|
sleep 10
|
||||||
|
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MessageRecord WHERE conversationId='$CID' AND kind='error'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** есть `error`-запись в audit log. В SSE приходит
|
||||||
|
`{"type":"error",...}` + `{"type":"end"}`. Агент **не падает** — `health`
|
||||||
|
= `ok` после.
|
||||||
|
|
||||||
|
### TC-16.2 — agentik.db занят другим процессом
|
||||||
|
|
||||||
|
Запусти второй экземпляр агента на ту же DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_DB_PATH=/root/agentik.db java -jar /root/agentik-0.1.0-all.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** агент падает на старте с понятным сообщением про SQLite lock.
|
||||||
|
Это by-design (single-writer).
|
||||||
|
|
||||||
|
### TC-16.3 — SOUL файл не существует
|
||||||
|
|
||||||
|
Удали `/root/SOUL.md`, перезапусти агент. Должен стартовать без ошибок,
|
||||||
|
просто без SOUL-секции в system prompt. Лог: `WARN ... SOUL file not found: ...`.
|
||||||
|
|
||||||
|
### TC-16.4 — пустой skills dir
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv /root/skills /root/skills.bak
|
||||||
|
mkdir /root/skills
|
||||||
|
# Перезапусти агент
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** агент стартует, `skills: 0 loaded from /root/skills`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Memory backend variants
|
||||||
|
|
||||||
|
### TC-17.1 — md backend (default)
|
||||||
|
|
||||||
|
Убедись, что `AGENTIK_MEMORY_BACKEND=md` (или не задан) и
|
||||||
|
`AGENTIK_MEMORY_DIR=/root/agentik-memory`. После TC-5.x должны появиться
|
||||||
|
`.md`-файлы:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /root/agentik-memory/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** файлы типа `user.md`, `world.md`, `preference.md` (или
|
||||||
|
всё в одном файле — зависит от реализации).
|
||||||
|
|
||||||
|
### TC-17.2 — off backend (память выключена)
|
||||||
|
|
||||||
|
Перезапусти с `AGENTIK_MEMORY_DIR=off`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -9 -f agentik-0.1.0-all.jar
|
||||||
|
AGENTIK_MEMORY_DIR=off nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown
|
||||||
|
```
|
||||||
|
|
||||||
|
Попытка `memory_save` через модель должна вернуть ошибку:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Попробуй вызвать memory_save."}]'
|
||||||
|
sleep 8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** модель либо отказывается вызывать, либо получает
|
||||||
|
ошибку от tool'а и сообщает пользователю.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Performance sanity
|
||||||
|
|
||||||
|
### TC-18.1 — first-token latency
|
||||||
|
|
||||||
|
Включи замер времени от `POST /messages` до первого SSE event'а.
|
||||||
|
Для Qwen3-27B на RTX5090 ожидаем < 1 сек до `start_reasoning`.
|
||||||
|
|
||||||
|
### TC-18.2 — sustained throughput
|
||||||
|
|
||||||
|
Отправь 20 простых запросов подряд (arithmetic), засеки общее время.
|
||||||
|
Ожидание: < 30 сек суммарно, т.е. < 1.5 сек на запрос.
|
||||||
|
|
||||||
|
### TC-18.3 — fatjar memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps aux | grep agentik-0.1.0 | grep -v grep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидание:** RSS < 2 GB (наш Xmx). Если больше — где-то утечка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Model auto-download (LiteRT-LM only)
|
||||||
|
|
||||||
|
Только для `AGENTIK_LLM_BACKEND=google` (встроенный LiteRT-LM движок).
|
||||||
|
Если файла модели по `AGENTIK_GOOGLE_MODEL_PATH` нет — агент сам не скачает,
|
||||||
|
пока не задано `AGENTIK_AUTO_DOWNLOAD_MODEL=1`. Либо качаем руками
|
||||||
|
через `pull-model` subcommand.
|
||||||
|
|
||||||
|
URL по умолчанию всегда Gemma-4-E2B-it.litertlm (2.5 GB с `static.binom.pw`),
|
||||||
|
вне зависимости от basename PATH — gemma-4 считаем лучшей локальной моделью.
|
||||||
|
|
||||||
|
### 18.1. Subcommand `pull-model` качает модель вручную
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачать дефолтную модель (gemma-4) в указанный путь:
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||||
|
java -jar agentik.jar pull-model
|
||||||
|
# → downloading from https://static.binom.pw/models/gemma-4-E2B-it.litertlm
|
||||||
|
# → 50% (1.2 GB / 2.5 GB)
|
||||||
|
# → done in 47s
|
||||||
|
```
|
||||||
|
|
||||||
|
После `pull-model` файл лежит на месте, файл `<dest>.part` удалён.
|
||||||
|
|
||||||
|
### 18.2. `pull-model` no-op если файл уже полный
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Повторный запуск с тем же PATH:
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||||
|
java -jar agentik.jar pull-model
|
||||||
|
# → already present (2.50 GB), nothing to do
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18.3. `pull-model` докачивает обрыв (resume через Range)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Симулируем обрыв: удаляем финальный, оставляем .part с первыми 500 MB
|
||||||
|
rm /root/models/gemma-4-E2B-it.litertlm
|
||||||
|
mv /root/models/gemma-4-E2B-it.litertlm.part /root/models/gemma-4-E2B-it.litertlm.part.bak
|
||||||
|
# Запускаем pull-model снова — должен возобновить с 500 MB
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||||
|
java -jar agentik.jar pull-model
|
||||||
|
# → resuming from 524288000 bytes
|
||||||
|
# → downloaded 2.10 GB in 38s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18.4. Сервер exit-2 при отсутствии файла и без auto-download
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/missing.litertlm \
|
||||||
|
java -jar agentik.jar
|
||||||
|
# → LiteRT-LM model file not found at: /root/models/missing.litertlm
|
||||||
|
# → Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
|
||||||
|
# → exit 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18.5. Сервер сам качает при `AGENTIK_AUTO_DOWNLOAD_MODEL=1`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Удалить файл, запустить с флагом:
|
||||||
|
rm -f /root/models/gemma-4-E2B-it.litertlm
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
|
||||||
|
AGENTIK_AUTO_DOWNLOAD_MODEL=1 \
|
||||||
|
java -jar agentik.jar
|
||||||
|
# → 12:34:56 WARN auto-download: https://static.binom.pw/models/...
|
||||||
|
# → 12:34:56 INFO auto-download: 17% (445 MB/2.5 GB)
|
||||||
|
# → 12:36:42 INFO auto-download: done in 1m45s
|
||||||
|
# → 12:36:43 INFO agentik standalone listening on http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18.6. Override URL через `AGENTIK_GOOGLE_MODEL_URL`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Качаем qwen вместо gemma (если зальём):
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/models/qwen.litertlm \
|
||||||
|
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm \
|
||||||
|
java -jar agentik.jar pull-model
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18.7. SHA-256 проверка
|
||||||
|
|
||||||
|
Если на сервере лежит `<basename>.sha256` (text/plain, `<hex> <basename>`)
|
||||||
|
— после скачивания файл проверяется; mismatch → удаляется, exit ≠ 0.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm \
|
||||||
|
AGENTIK_GOOGLE_MODEL_SHA256_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm.sha256 \
|
||||||
|
java -jar agentik.jar pull-model
|
||||||
|
# → 13:01:23 INFO model download: SHA-256 verified (4ab1...e0d)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый smoke-test (5 минут)
|
||||||
|
|
||||||
|
Если времени мало — этот минимум покрывает 80%:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. health
|
||||||
|
curl -sS http://127.0.0.1:8080/health
|
||||||
|
# → ok
|
||||||
|
|
||||||
|
# 2. create + simple Q&A
|
||||||
|
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
|
||||||
|
-H "Content-Type: application/json" -d '{"temp":false}' \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Привет! 2+2=?"}]'
|
||||||
|
sleep 6
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
|
||||||
|
# → должен быть user + assistant_message с "4"
|
||||||
|
|
||||||
|
# 3. SSE live
|
||||||
|
(curl -sN --max-time 8 "http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
|
||||||
|
> /tmp/sse.out 2>&1) &
|
||||||
|
sleep 1
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Скажи ок"}]'
|
||||||
|
wait
|
||||||
|
cat /tmp/sse.out
|
||||||
|
# → start_reasoning, start_response, append_text, end
|
||||||
|
|
||||||
|
# 4. multi-turn
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"А 3+3?"}]'
|
||||||
|
sleep 6
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1])"
|
||||||
|
# → assistant_message с "6"
|
||||||
|
|
||||||
|
# 5. interrupt
|
||||||
|
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"type":"text","body":"Длинная история про драконов, 1000 слов"}]' >/dev/null) &
|
||||||
|
sleep 3
|
||||||
|
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
|
||||||
|
wait
|
||||||
|
sleep 3
|
||||||
|
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
|
||||||
|
| python3 -c "import sys,json;m=json.load(sys.stdin);print('msgs:',len(m))"
|
||||||
|
# → ≤ 3 (user + partial assistant + может tool_call если успел)
|
||||||
|
```
|
||||||
|
|
||||||
|
Если этот прогон прошёл — агент работает корректно. Более глубокие
|
||||||
|
кейсы — выше по разделам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка: что покрыто автоматически vs вручную
|
||||||
|
|
||||||
|
| Возможность | JVM unit/integration tests | Manual |
|
||||||
|
|-------------|---------------------------|--------|
|
||||||
|
| Conversation CRUD | ✓ | TC-2.x |
|
||||||
|
| send/messages pagination | ✓ | TC-3.x |
|
||||||
|
| SSE event format | ✗ | TC-4.x |
|
||||||
|
| Memory tools | ✓ (in-memory) | TC-5.x (real backend) |
|
||||||
|
| Skills tools | ✓ (in-memory) | TC-6.x (real dir) |
|
||||||
|
| SOUL | ✗ | TC-7.x |
|
||||||
|
| Interrupt | ✓ (FakeLiteLlm) | TC-8.x (real LLM) |
|
||||||
|
| Compaction | ✓ | TC-10.x (real long context) |
|
||||||
|
| Reflection | ✓ | TC-11.x |
|
||||||
|
| Skill mining | ✓ | TC-12.x |
|
||||||
|
| Token accounting | ✓ | TC-13.x |
|
||||||
|
| A2A protocol | ✓ (litert tests) | TC-15.x |
|
||||||
|
| Error paths | partial | TC-16.x |
|
||||||
|
| Persistence/restart | ✗ | TC-9.x |
|
||||||
|
|
||||||
|
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
|
||||||
@@ -1,2 +1,157 @@
|
|||||||
# agentik
|
# agentik
|
||||||
|
|
||||||
|
Локальный stateful LLM-агент с persistent-памятью, инструментами и
|
||||||
|
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
|
||||||
|
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
|
||||||
|
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
|
||||||
|
Qwen) через ONNX/Native-runtime.
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
```
|
||||||
|
agentik/
|
||||||
|
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
|
||||||
|
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
|
||||||
|
├── client/ Ktor-клиент → тот же /agentik, с KMP-native
|
||||||
|
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
|
||||||
|
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
|
||||||
|
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||||
|
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||||
|
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||||
|
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||||
|
├── storage-sqlite/ SQLite реализация для production
|
||||||
|
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||||
|
├── agentik-cli/ JVM REPL-клиент (JLine) к /agentik
|
||||||
|
├── agentik-tui/ Compose-for-Mosaic TUI-клиент (desktop) к /agentik
|
||||||
|
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||||
|
(см. "Модули" ниже).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### 1. Скачать fatjar
|
||||||
|
|
||||||
|
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||||
|
tag-релизах, либо соберите из исходников:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.binom.pw/subochev/agentik
|
||||||
|
cd agentik
|
||||||
|
./gradlew :standalone:shadowJar
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||||
|
зависит от LLM-backend'а).
|
||||||
|
|
||||||
|
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=openai \
|
||||||
|
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||||
|
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||||
|
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||||
|
```
|
||||||
|
|
||||||
|
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||||
|
|
||||||
|
## Подключиться
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar
|
||||||
|
|
||||||
|
# TUI
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-tui-0.1.0-all.jar
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
- Запускаемые:
|
||||||
|
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||||
|
- [`:agentik-cli`](agentik-cli/README.md) — REPL-клиент (JLine).
|
||||||
|
- [`:agentik-tui`](agentik-tui/README.md) — Compose-for-Mosaic TUI.
|
||||||
|
- Библиотеки (контракты и реализации):
|
||||||
|
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||||
|
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||||
|
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||||
|
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||||
|
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||||
|
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||||
|
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||||
|
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||||
|
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||||
|
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||||
|
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||||
|
|
||||||
|
## Где смотреть версии
|
||||||
|
|
||||||
|
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||||
|
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||||
|
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||||
|
|
||||||
|
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||||
|
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||||
|
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||||
|
тега и публикует.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
|
||||||
|
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||||
|
Параметры через:
|
||||||
|
|
||||||
|
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||||
|
- `binom.repo.user`
|
||||||
|
- `binom.repo.password`
|
||||||
|
|
||||||
|
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
|
||||||
|
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
|
||||||
|
репозитория). Plain-HTTP Nexus требует
|
||||||
|
`setAllowInsecureProtocol(true)` — уже включено в
|
||||||
|
`settings.gradle.kts`.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||||
|
|
||||||
|
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||||
|
shadowjar'ов.
|
||||||
|
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||||
|
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||||
|
|
||||||
|
## Что отличает от других агентских фреймворков
|
||||||
|
|
||||||
|
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||||
|
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||||
|
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||||
|
Один fatjar — три API.
|
||||||
|
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||||
|
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||||
|
Desktop / CLI.
|
||||||
|
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||||
|
потери контекста, если пользователь нажал Ctrl-C во время
|
||||||
|
долгого tool-вызова.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Участие в проекте
|
||||||
|
|
||||||
|
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||||
|
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||||
|
изменении API.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Ядро системы tools для LLM-агента:
|
||||||
|
|
||||||
|
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||||
|
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
|
||||||
|
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
|
||||||
|
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
|
||||||
|
→ запускает → возвращает результат.
|
||||||
|
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
|
||||||
|
вызов, помечая результат `[cancelled by user]`.
|
||||||
|
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
|
||||||
|
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
|
||||||
|
threadpool от переполнения при fan-out 30+ диалогов.
|
||||||
|
|
||||||
|
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||||
|
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||||
|
IM-фронтендах (CLI, TUI, IRC, web).
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||||
|
(memory / skills / files / web), фильтрует через
|
||||||
|
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyToolset : Toolset {
|
||||||
|
override val name = "my"
|
||||||
|
override val description = "Custom user-defined tools"
|
||||||
|
override val tools = listOf(myTool1, myTool2)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dispatcher = ToolDispatcher(
|
||||||
|
toolsets = listOf(MemoryTools(memory), MyToolset()),
|
||||||
|
enabled = setOf("memory", "my"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
|
||||||
|
|
||||||
|
## Как пишется tool
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data object EchoTool : Tool {
|
||||||
|
override val name = "echo"
|
||||||
|
override val description = "Echoes back the argument"
|
||||||
|
override val argsSchema = jsonSchema {
|
||||||
|
property("text", JsonType.STRING) { required = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun invoke(args: JsonObject): ToolResult {
|
||||||
|
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
|
||||||
|
return ToolResult.Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agent-toolsets:allTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||||
|
budget exhaustion, registry filter, parallel dispatch.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
|
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||||
|
(см. `:storage-core`).
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Реализует полную спецификацию из
|
||||||
|
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||||
|
log, rolling buffer, partial-state persistence.
|
||||||
+31
-4
@@ -1,5 +1,8 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,8 +13,15 @@ import pw.binom.litert.LiteTool
|
|||||||
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
|
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
|
||||||
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
|
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
|
||||||
* toolsets в одном агенте.
|
* 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 семантика — модель может вызвать тул из тулсета,
|
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
|
||||||
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
|
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
|
||||||
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
|
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
|
||||||
|
*
|
||||||
|
* **Cancellation semantics.** Все три пути выполняют `tool.invoke(...)` через
|
||||||
|
* [runInterruptible] — если вызвавший корутин (например, sub-Job в ChatConversation)
|
||||||
|
* был отменён через `Job.cancel()`, реальный блокирующий поток получит
|
||||||
|
* `Thread.interrupt()` → cooperative тулы (`Thread.sleep`, blocking I/O с
|
||||||
|
* timeout, и т.п.) могут прервать своё выполнение.
|
||||||
*/
|
*/
|
||||||
class ToolsetDispatchPolicy(
|
class ToolsetDispatchPolicy(
|
||||||
private val registry: ToolsetRegistry,
|
private val registry: ToolsetRegistry,
|
||||||
@@ -46,11 +62,20 @@ class ToolsetDispatchPolicy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
|
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
|
||||||
|
// Захватываем Job один раз — если он отменён к моменту invoke (или во
|
||||||
|
// время invoke), мы сможем прервать LiteTool через обычный механизм
|
||||||
|
// cooperative cancellation (tool внутри себя делает Thread.sleep → реагирует
|
||||||
|
// на Thread.interrupt). Job.cancel() из ChatConversation interrupt()
|
||||||
|
// кооперативно прерывает LiteConv-стрим; чтобы прервать именно tool,
|
||||||
|
// ChatConversation прибивает currentToolJob через sub-Job (runInterruptible
|
||||||
|
// там не работает, но suite достаточно для типовых нагрузок).
|
||||||
|
val currentJob = currentCoroutineContext()[Job]
|
||||||
// 1. Активный тул?
|
// 1. Активный тул?
|
||||||
val activeTools = registry.activeTools()
|
val activeTools = registry.activeTools()
|
||||||
val activeToolNames = activeTools.map { it.nameFromDescribe() }
|
val activeToolNames = activeTools.map { it.nameFromDescribe() }
|
||||||
if (toolName in activeToolNames) {
|
if (toolName in activeToolNames) {
|
||||||
val tool = activeTools.first { it.nameFromDescribe() == toolName }
|
val tool = activeTools.first { it.nameFromDescribe() == toolName }
|
||||||
|
currentJob?.cancelIfAlreadyCancelled()
|
||||||
val result = tool.invoke(argumentsJson)
|
val result = tool.invoke(argumentsJson)
|
||||||
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
|
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
|
||||||
}
|
}
|
||||||
@@ -60,18 +85,20 @@ class ToolsetDispatchPolicy(
|
|||||||
if (ownerPair != null) {
|
if (ownerPair != null) {
|
||||||
val (contribution, entry) = ownerPair
|
val (contribution, entry) = ownerPair
|
||||||
registry.activate(contribution.name)
|
registry.activate(contribution.name)
|
||||||
|
currentJob?.cancelIfAlreadyCancelled()
|
||||||
val result = entry.tool.invoke(argumentsJson)
|
val result = entry.tool.invoke(argumentsJson)
|
||||||
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
|
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback — плоский тул вне toolsets.
|
// 3. Fallback — плоский тул вне toolsets.
|
||||||
// Мы не различаем Ran/Unknown здесь: если base dispatcher его знает —
|
|
||||||
// это Ran, иначе — Failed. Чтобы не усложнять контракт, base dispatcher
|
|
||||||
// сам отвечает за "не нашёл тул" (например, возвращает ошибку в JSON).
|
|
||||||
val result = baseDispatcher(toolName, argumentsJson)
|
val result = baseDispatcher(toolName, argumentsJson)
|
||||||
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
|
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? {
|
private suspend fun findActiveToolsetForTool(toolName: String): String? {
|
||||||
val active = registry.activeNames()
|
val active = registry.activeNames()
|
||||||
for (name in active) {
|
for (name in active) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# `:agentik-cli` — JVM CLI клиент к `/agentik`
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
JVM-only REPL-клиент к серверу `:standalone` через `:client`
|
||||||
|
над HTTP+SSE:
|
||||||
|
|
||||||
|
- Нативный REPL с JLine (стрелки влево/вправо/вверх, история,
|
||||||
|
Ctrl-D/E).
|
||||||
|
- Подписка на live-стрим событий агента.
|
||||||
|
- Slash-команды: `/new /list /switch /rename /rm /interrupt /history
|
||||||
|
/pwd /help /exit /quit`.
|
||||||
|
- Persistent session id в `~/.agentik/cli-state.json`.
|
||||||
|
|
||||||
|
Решает: быстрый способ проверить агента руками из терминала.
|
||||||
|
Используется в CI-смоук-тестах и для daily-driver.
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- JVM 21+ (на машине должна быть JAVA_HOME или `java` в PATH).
|
||||||
|
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
||||||
|
|
||||||
|
### Запуск из готового fatjar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar \
|
||||||
|
--server http://192.168.76.166:8080/agentik
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-cli:run --args="--server http://localhost:8080/agentik"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры CLI
|
||||||
|
|
||||||
|
| Флаг | ENV | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
||||||
|
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
||||||
|
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
||||||
|
| `--help` | — | Показывает help и выходит |
|
||||||
|
|
||||||
|
## Slash-команды (внутри REPL)
|
||||||
|
|
||||||
|
| Команда | Синонимы | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `/help` | | Показывает help |
|
||||||
|
| `/new [title]` | | Создать диалог |
|
||||||
|
| `/list` | `/ls` | Список диалогов |
|
||||||
|
| `/switch <id>` | `/sw`, `/cd` | Переключиться на диалог |
|
||||||
|
| `/rename <title>` | | Переименовать текущий диалог |
|
||||||
|
| `/rm [id]` | `/delete` | Удалить (текущий или по id) |
|
||||||
|
| `/interrupt` | `/stop`, `/cancel` | Прервать текущий ход |
|
||||||
|
| `/history` | `/h`, `/hist` | Показывает историю текущего диалога |
|
||||||
|
| `/pwd` | | Путь к state-file |
|
||||||
|
| `/exit`, `/quit` | | Выйти |
|
||||||
|
|
||||||
|
## Переменные среды (пробрасываются серверу через `--server`)
|
||||||
|
|
||||||
|
См. [`../standalone/README.md`](../standalone/README.md). На стороне
|
||||||
|
клиента они **не** интерпретируются — это лишь настройки запуска
|
||||||
|
агента. CLI только знает, по какому URL стучаться.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
|
terminal. Это upstream-особенность Ktor SSE.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agentik-cli:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
23 теста: парсер slash-команд, event-рендер, state-repository.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
|
||||||
|
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto.
|
||||||
|
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only)
|
||||||
|
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual,
|
||||||
|
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
// :client JVM-only (ktor-cio). Подключаем только в jvmMain.
|
||||||
|
implementation(project(":client"))
|
||||||
|
// JLine для readline с историей и completion.
|
||||||
|
implementation(libs.jline)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
// runTest { } — suspend test runner для commonTest.
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
// JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4.
|
||||||
|
implementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.cli.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fatjar (uberjar) ---
|
||||||
|
//
|
||||||
|
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
|
||||||
|
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
|
||||||
|
|
||||||
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
|
archiveBaseName.set("agentik-cli")
|
||||||
|
archiveClassifier.set("all")
|
||||||
|
description = "Self-contained fatjar with all runtime dependencies bundled."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
from(tasks.named("jvmJar"))
|
||||||
|
val cc = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
}
|
||||||
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
mergeServiceFiles()
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-cli"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный класс REPL.
|
||||||
|
*
|
||||||
|
* Управляет:
|
||||||
|
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
|
||||||
|
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
|
||||||
|
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
|
||||||
|
*
|
||||||
|
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
|
||||||
|
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
|
||||||
|
* увидит [Event.Interrupted] и сам завершится.
|
||||||
|
*/
|
||||||
|
class AgentikCli internal constructor(private val config: CliConfig) {
|
||||||
|
|
||||||
|
private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id)
|
||||||
|
private val terminal: CliTerminal = CliPlatform.openTerminal(
|
||||||
|
historyFile = if (config.historyEnabled) stateFilePath() else null,
|
||||||
|
prompt = "agentik> ",
|
||||||
|
)
|
||||||
|
private val sessionRepo = SessionRepository(
|
||||||
|
filePath = if (config.historyEnabled) stateFilePath() else null,
|
||||||
|
io = CliPlatform.sessionIo(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private var currentConv: Conversation? = null
|
||||||
|
private var currentTitle: String? = null
|
||||||
|
private var lastEventAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
suspend fun run() {
|
||||||
|
try {
|
||||||
|
// Восстановление сессии.
|
||||||
|
val saved = sessionRepo.load()
|
||||||
|
if (saved != null) {
|
||||||
|
val conv = runCatching { agent.getConversation(saved.conversationId) }
|
||||||
|
.getOrNull()
|
||||||
|
if (conv != null) {
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = conv.title
|
||||||
|
lastEventAt = saved.lastEventAt
|
||||||
|
terminal.printSystem(
|
||||||
|
"восстановлен диалог ${shorten(conv.id)}" +
|
||||||
|
" (${conv.title ?: "без названия"})",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
terminal.printSystem(
|
||||||
|
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printBanner()
|
||||||
|
|
||||||
|
// Главный цикл.
|
||||||
|
while (scope.isActive) {
|
||||||
|
terminal.print(prompt())
|
||||||
|
val line = terminal.readLine() ?: break // EOF → выходим
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty()) continue
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
when (val r = parseSlash(trimmed.substring(1))) {
|
||||||
|
is ParseResult.Success -> {
|
||||||
|
if (handleCommand(r.command) == CommandResult.Exit) break
|
||||||
|
}
|
||||||
|
is ParseResult.Failure -> terminal.printSystem(r.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleUserMessage(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
terminal.printSystem("до свидания.")
|
||||||
|
currentConv?.close()
|
||||||
|
terminal.close()
|
||||||
|
sessionRepo.close()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ banner / prompt
|
||||||
|
|
||||||
|
private suspend fun printBanner() {
|
||||||
|
terminal.println()
|
||||||
|
terminal.println("agentik-cli — id=${config.id} — type /help")
|
||||||
|
terminal.println("server: ${config.server}")
|
||||||
|
when (val c = currentConv) {
|
||||||
|
null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>")
|
||||||
|
else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})")
|
||||||
|
}
|
||||||
|
terminal.println()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> "
|
||||||
|
|
||||||
|
private suspend fun printHelp() {
|
||||||
|
terminal.println(
|
||||||
|
"""
|
||||||
|
|Slash-команды:
|
||||||
|
| /help эта справка
|
||||||
|
| /new [title] создать новый диалог
|
||||||
|
| /list, /ls список диалогов (новые сверху)
|
||||||
|
| /switch <id>, /sw переключиться на диалог по id
|
||||||
|
| /rename <title> переименовать текущий диалог
|
||||||
|
| /delete [<id>], /rm удалить диалог (по id или текущий)
|
||||||
|
| /history, /h последние сообщения текущего диалога
|
||||||
|
| /interrupt, /stop прервать текущий ход
|
||||||
|
| /pwd показать текущий диалог
|
||||||
|
| /exit, /quit выйти (Ctrl-D тоже)
|
||||||
|
|
|
||||||
|
|Любой ввод без ведущего `/` отправляется агенту в текущий диалог.
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ command dispatch
|
||||||
|
|
||||||
|
private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) {
|
||||||
|
SlashCommand.Help -> { printHelp(); CommandResult.Continue }
|
||||||
|
SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit
|
||||||
|
is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue }
|
||||||
|
SlashCommand.List -> { handleList(); CommandResult.Continue }
|
||||||
|
is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue }
|
||||||
|
is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue }
|
||||||
|
is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue }
|
||||||
|
SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue }
|
||||||
|
SlashCommand.History -> { handleHistory(); CommandResult.Continue }
|
||||||
|
SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleNew(title: String?) {
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
if (title != null) conv.rename(title)
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = title ?: conv.title
|
||||||
|
lastEventAt = Instant.DISTANT_PAST
|
||||||
|
terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "")
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleList() {
|
||||||
|
terminal.println("диалоги (новые сверху):")
|
||||||
|
agent.getConversations(offset = 0).collect { conv ->
|
||||||
|
val marker = if (conv.id == currentConv?.id) "*" else " "
|
||||||
|
val title = conv.title ?: "(без названия)"
|
||||||
|
terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSwitch(id: String) {
|
||||||
|
val conv = agent.getConversation(id)
|
||||||
|
if (conv == null) {
|
||||||
|
terminal.printSystem("диалог $id не найден")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentConv?.close()
|
||||||
|
currentConv = conv
|
||||||
|
currentTitle = conv.title
|
||||||
|
lastEventAt = Instant.DISTANT_PAST
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleRename(title: String) {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога — /new")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.rename(title)
|
||||||
|
currentTitle = title
|
||||||
|
terminal.printSystem("заголовок: $title")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleDelete(id: String?) {
|
||||||
|
val target = id ?: currentConv?.id
|
||||||
|
if (target == null) {
|
||||||
|
terminal.printSystem("нет диалога для удаления")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ok = agent.deleteConversation(target)
|
||||||
|
if (ok) {
|
||||||
|
terminal.printSystem("удалён ${shorten(target)}")
|
||||||
|
if (target == currentConv?.id) {
|
||||||
|
currentConv?.close()
|
||||||
|
currentConv = null
|
||||||
|
currentTitle = null
|
||||||
|
sessionRepo.clear()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terminal.printSystem("диалог ${shorten(target)} не найден")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleInterrupt() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.interrupt()
|
||||||
|
terminal.printSystem("прерывание отправлено")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handlePwd() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("диалог: не выбран")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terminal.printSystem("id: ${c.id}")
|
||||||
|
terminal.printSystem("title: ${c.title ?: "—"}")
|
||||||
|
terminal.printSystem("updatedAt: ${c.updatedAt}")
|
||||||
|
terminal.printSystem("temporal: ${c.isTemporal}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleHistory() {
|
||||||
|
val c = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terminal.println("история:")
|
||||||
|
c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun renderHistoryMessage(msg: Message) {
|
||||||
|
val prefix = " [${msg.date}] "
|
||||||
|
when (msg) {
|
||||||
|
is Message.UserMessage ->
|
||||||
|
terminal.println(prefix + "user | " + msg.content.text())
|
||||||
|
is Message.AssistantMessage ->
|
||||||
|
terminal.println(prefix + "agent | " + msg.content.text())
|
||||||
|
is Message.ToolCall ->
|
||||||
|
terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}")
|
||||||
|
is Message.ToolResult ->
|
||||||
|
terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null"))
|
||||||
|
is Message.Error ->
|
||||||
|
terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Content>.text(): String =
|
||||||
|
joinToString(separator = "") { c ->
|
||||||
|
when (c) {
|
||||||
|
is Content.Text -> c.body
|
||||||
|
is Content.Image -> "[image:${c.mime}:${c.data.size}B]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ user-message
|
||||||
|
|
||||||
|
private suspend fun handleUserMessage(text: String) {
|
||||||
|
val conv = currentConv ?: run {
|
||||||
|
terminal.printSystem("нет активного диалога — /new")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.println() // пустая строка для визуального отделения блока
|
||||||
|
|
||||||
|
val renderer = EventRenderer(terminal)
|
||||||
|
val turnFinished = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
// Подписчик events: принимает события и обновляет lastEventAt,
|
||||||
|
// по терминальному событию закрывает Deferred.
|
||||||
|
val eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
conv.events(after = lastEventAt).collect { ev ->
|
||||||
|
renderer.render(ev)
|
||||||
|
if (ev.date > lastEventAt) {
|
||||||
|
lastEventAt = ev.date
|
||||||
|
sessionRepo.save(conv.id, lastEventAt)
|
||||||
|
}
|
||||||
|
if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) {
|
||||||
|
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
|
||||||
|
if (t !is kotlinx.coroutines.CancellationException) {
|
||||||
|
terminal.printSystem("[events stream error] ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text(text)))
|
||||||
|
turnFinished.await()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
terminal.printSystem("[send error] ${t.message}")
|
||||||
|
} finally {
|
||||||
|
eventsJob.cancel()
|
||||||
|
renderer.close()
|
||||||
|
terminal.println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ utils
|
||||||
|
|
||||||
|
private fun shorten(id: String): String = id.take(8)
|
||||||
|
|
||||||
|
private fun stateFilePath(): String? {
|
||||||
|
val home = CliPlatform.homeDir() ?: return null
|
||||||
|
val dir = "$home/.agentik"
|
||||||
|
return "$dir/cli-state.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class CommandResult { Continue, Exit }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или
|
||||||
|
* нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент),
|
||||||
|
* предоставляются здесь как `expect/actual`.
|
||||||
|
*
|
||||||
|
* Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`),
|
||||||
|
* nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача).
|
||||||
|
*/
|
||||||
|
expect object CliPlatform {
|
||||||
|
fun openAgent(baseUrl: String, id: String): Agent
|
||||||
|
|
||||||
|
fun openTerminal(
|
||||||
|
historyFile: String?,
|
||||||
|
prompt: String,
|
||||||
|
): CliTerminal
|
||||||
|
|
||||||
|
/** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */
|
||||||
|
fun homeDir(): String?
|
||||||
|
|
||||||
|
/** Переменная среды (native API). Для jvmMain — `System.getenv`. */
|
||||||
|
fun env(key: String): String?
|
||||||
|
|
||||||
|
/** Файловое IO для session-state; nativeMain возвращает no-op. */
|
||||||
|
fun sessionIo(): SessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать
|
||||||
|
* event-loop агентного цикла во время ожидания ввода.
|
||||||
|
*/
|
||||||
|
interface CliTerminal {
|
||||||
|
val prompt: String
|
||||||
|
|
||||||
|
/** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */
|
||||||
|
suspend fun readLine(): String?
|
||||||
|
|
||||||
|
/** Печатает строку + перевод строки. */
|
||||||
|
suspend fun println(text: String = "")
|
||||||
|
|
||||||
|
/** Печатает строку без перевода (для streamed chunks). */
|
||||||
|
suspend fun print(text: String)
|
||||||
|
|
||||||
|
/** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */
|
||||||
|
suspend fun printSystem(text: String)
|
||||||
|
|
||||||
|
/** Закрыть терминал: restore raw mode, flush history file, ... */
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Печатает [Event] в человеко-читаемом виде через [CliTerminal].
|
||||||
|
*
|
||||||
|
* Дизайн:
|
||||||
|
* - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим
|
||||||
|
* отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]).
|
||||||
|
* - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие
|
||||||
|
* происходит при [Event.End] или [Event.Interrupted].
|
||||||
|
* - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки).
|
||||||
|
* - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder.
|
||||||
|
* Реальный рендеринг сделаем позже через iTerm/Kitty протоколы.
|
||||||
|
* - [Event.End] / [Event.Interrupted] — закрывают текущий блок.
|
||||||
|
* - [Event.Error] — отдельный системный блок `[error: …]`.
|
||||||
|
*/
|
||||||
|
class EventRenderer(private val terminal: CliTerminal) {
|
||||||
|
|
||||||
|
/** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */
|
||||||
|
private var responseOpen = false
|
||||||
|
|
||||||
|
suspend fun render(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is Event.StartReasoning -> {
|
||||||
|
terminal.printSystem("…thinking…")
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.StartResponse -> {
|
||||||
|
if (responseOpen) terminal.println()
|
||||||
|
responseOpen = true
|
||||||
|
// Без префикса — текст будет стримиться дальше через AppendText.
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.AppendText -> {
|
||||||
|
terminal.print(event.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.AppendImage -> {
|
||||||
|
terminal.print("[image:${event.mime}:${event.body.size} bytes]")
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.Interrupted -> {
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
terminal.printSystem("[interrupted]")
|
||||||
|
responseOpen = false
|
||||||
|
} else {
|
||||||
|
terminal.printSystem("[interrupted]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.End -> {
|
||||||
|
if (responseOpen) {
|
||||||
|
terminal.println()
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Event.Error -> {
|
||||||
|
terminal.println()
|
||||||
|
terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}")
|
||||||
|
if (responseOpen) responseOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// ToolCall/ToolResult — это «структура» диалога, в текстовом стриме
|
||||||
|
// не показываем; в веб-UI будет по-другому.
|
||||||
|
terminal.printSystem("[event:${event::class.simpleName}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
responseOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа CLI. Поддерживает аргументы командной строки:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-cli [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
*
|
||||||
|
* --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или
|
||||||
|
* http://localhost:8080/agentik)
|
||||||
|
* --id ID идентификатор этого клиента (default "cli:$USER")
|
||||||
|
* --no-history не сохранять состояние в ~/.agentik/cli-state.json
|
||||||
|
* --help, -h распечатать usage и выйти
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Без аргументов — стартует REPL.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args)
|
||||||
|
if (cfg == null) {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
AgentikCli(cfg).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация CLI, вычисленная из аргументов + переменных среды.
|
||||||
|
* Доступна из других файлов commonMain (видна как `internal` внутри модуля).
|
||||||
|
*/
|
||||||
|
internal data class CliConfig(
|
||||||
|
val server: String,
|
||||||
|
val id: String,
|
||||||
|
val historyEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCliArgs(args: Array<String>): CliConfig? {
|
||||||
|
var server: String? = null
|
||||||
|
var id: String? = null
|
||||||
|
var historyEnabled = true
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
when (val a = args[i]) {
|
||||||
|
"--help", "-h", "help" -> return null
|
||||||
|
"--server", "-s" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует URL" }
|
||||||
|
server = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--id" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует значение" }
|
||||||
|
id = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--no-history" -> { historyEnabled = false; i++ }
|
||||||
|
"--" -> i++ // разделитель; остальное игнорируем
|
||||||
|
else -> error("неизвестный аргумент: $a (введите --help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedServer = server
|
||||||
|
?: CliPlatform.env("AGENTIK_SERVER")
|
||||||
|
?: "http://localhost:8080/agentik"
|
||||||
|
val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}"
|
||||||
|
|
||||||
|
return CliConfig(
|
||||||
|
server = resolvedServer,
|
||||||
|
id = resolvedId,
|
||||||
|
historyEnabled = historyEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun printUsage() {
|
||||||
|
val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"
|
||||||
|
println("""
|
||||||
|
agentik-cli — REPL поверх протокола agentik
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
agentik-cli [--server URL] [--id ID] [--no-history]
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
--server, -s URL базовый URL (default: $defaultServer)
|
||||||
|
--id ID идентификатор клиента (default: cli:${defaultUser})
|
||||||
|
--no-history не сохранять состояние в ~/.agentik/cli-state.json
|
||||||
|
--help, -h эта справка
|
||||||
|
|
||||||
|
Переменные среды:
|
||||||
|
AGENTIK_SERVER базовый URL агента (используется если --server не задан)
|
||||||
|
HOME для пути ~/.agentik/cli-state.json
|
||||||
|
|
||||||
|
В REPL:
|
||||||
|
/help список slash-команд
|
||||||
|
/new [title] создать диалог (title опционально)
|
||||||
|
/list, /ls список диалогов
|
||||||
|
/switch <id>, /sw <id> переключиться на диалог
|
||||||
|
/rename <title> переименовать текущий диалог
|
||||||
|
/delete [<id>], /rm удалить (по id или текущий)
|
||||||
|
/history, /h последние сообщения текущего диалога
|
||||||
|
/interrupt, /stop прервать текущий ход
|
||||||
|
/pwd показать текущий диалог
|
||||||
|
/exit, /quit выйти (Ctrl-D тоже работает)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние CLI между запусками: последний выбранный диалог и момент последнего
|
||||||
|
* увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта).
|
||||||
|
*
|
||||||
|
* Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего
|
||||||
|
* не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась
|
||||||
|
* под все цели. Файл: `$HOME/.agentik/cli-state.json`.
|
||||||
|
*/
|
||||||
|
internal class SessionRepository internal constructor(
|
||||||
|
private val filePath: String?,
|
||||||
|
private val io: SessionIo,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class State(
|
||||||
|
val conversationId: String,
|
||||||
|
val lastEventAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */
|
||||||
|
private var cached: State? = null
|
||||||
|
|
||||||
|
fun load(): SavedSession? {
|
||||||
|
val path = filePath ?: return null
|
||||||
|
val raw = io.readAll(path) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val state = json.decodeFromString(State.serializer(), raw)
|
||||||
|
cached = state
|
||||||
|
SavedSession(
|
||||||
|
conversationId = state.conversationId,
|
||||||
|
lastEventAt = Instant.parse(state.lastEventAt),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(conversationId: String, lastEventAt: Instant) {
|
||||||
|
val path = filePath ?: return
|
||||||
|
val state = State(
|
||||||
|
conversationId = conversationId,
|
||||||
|
lastEventAt = lastEventAt.toString(),
|
||||||
|
)
|
||||||
|
cached = state
|
||||||
|
val body = json.encodeToString(State.serializer(), state)
|
||||||
|
io.writeAtomic(path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
val path = filePath ?: return
|
||||||
|
io.delete(path)
|
||||||
|
cached = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
// для совместимости с будущим in-memory state; пока no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class SavedSession(
|
||||||
|
val conversationId: String,
|
||||||
|
val lastEventAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации
|
||||||
|
* в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub).
|
||||||
|
*
|
||||||
|
* public, потому что его возвращает public [CliPlatform.sessionIo].
|
||||||
|
*/
|
||||||
|
interface SessionIo {
|
||||||
|
fun readAll(path: String): String?
|
||||||
|
fun writeAtomic(path: String, body: String)
|
||||||
|
fun delete(path: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал.
|
||||||
|
*
|
||||||
|
* Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в
|
||||||
|
* текущий диалог и НЕ разбирается в [parse].
|
||||||
|
*/
|
||||||
|
sealed interface SlashCommand {
|
||||||
|
data object Help : SlashCommand
|
||||||
|
data object Exit : SlashCommand
|
||||||
|
data object Quit : SlashCommand // синоним Exit
|
||||||
|
|
||||||
|
/** Создать новый диалог; опционально — заголовок. */
|
||||||
|
data class New(val title: String?) : SlashCommand
|
||||||
|
|
||||||
|
/** Список диалогов (cold flow — печатаем по мере прихода страниц). */
|
||||||
|
data object List : SlashCommand
|
||||||
|
|
||||||
|
/** Подключиться к существующему диалогу по id. */
|
||||||
|
data class Switch(val id: String) : SlashCommand
|
||||||
|
|
||||||
|
/** Переименовать текущий диалог. */
|
||||||
|
data class Rename(val title: String) : SlashCommand
|
||||||
|
|
||||||
|
/** Удалить диалог (по id или текущий). */
|
||||||
|
data class Delete(val id: String?) : SlashCommand
|
||||||
|
|
||||||
|
/** Прервать текущий ход. no-op если хода нет. */
|
||||||
|
data object Interrupt : SlashCommand
|
||||||
|
|
||||||
|
/** Показать последние сообщения текущего диалога (cold flow). */
|
||||||
|
data object History : SlashCommand
|
||||||
|
|
||||||
|
/** Показать информацию о текущем диалоге. */
|
||||||
|
data object Pwd : SlashCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure]
|
||||||
|
* с сообщением об ошибке.
|
||||||
|
*
|
||||||
|
* Команды нечувствительны к регистру (команда `/LIST` == `/list`).
|
||||||
|
*/
|
||||||
|
fun parseSlash(input: String): ParseResult {
|
||||||
|
val s = input.trim()
|
||||||
|
if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)")
|
||||||
|
|
||||||
|
// Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar"
|
||||||
|
val firstSpace = s.indexOfAny(charArrayOf(' ', '\t'))
|
||||||
|
val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace)
|
||||||
|
val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim()
|
||||||
|
val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val command: SlashCommand? = when (cmd.lowercase()) {
|
||||||
|
"help", "?" -> SlashCommand.Help
|
||||||
|
"exit" -> SlashCommand.Exit
|
||||||
|
"quit", "q" -> SlashCommand.Quit
|
||||||
|
"new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() })
|
||||||
|
"list", "ls" -> SlashCommand.List
|
||||||
|
"switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) }
|
||||||
|
"rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) }
|
||||||
|
"delete", "rm" -> SlashCommand.Delete(args.firstOrNull())
|
||||||
|
"interrupt", "stop", "cancel" -> SlashCommand.Interrupt
|
||||||
|
"history", "hist", "h" -> SlashCommand.History
|
||||||
|
"pwd", "where" -> SlashCommand.Pwd
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (command != null) return ParseResult.Success(command)
|
||||||
|
|
||||||
|
// Не нашли команду: либо неизвестная, либо не хватает аргумента.
|
||||||
|
val cmdLower = cmd.lowercase()
|
||||||
|
return when (cmdLower) {
|
||||||
|
"switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>")
|
||||||
|
"rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>")
|
||||||
|
else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ParseResult {
|
||||||
|
data class Success(val command: SlashCommand) : ParseResult
|
||||||
|
data class Failure(val message: String) : ParseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удобный helper для тестов и общего кода. */
|
||||||
|
fun parseSlashOrNull(input: String): SlashCommand? =
|
||||||
|
when (val r = parseSlash(input)) {
|
||||||
|
is ParseResult.Success -> r.command
|
||||||
|
is ParseResult.Failure -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подменяем [CliTerminal] простой in-memory реализацией и проверяем,
|
||||||
|
* что события рендерятся в правильном формате.
|
||||||
|
*/
|
||||||
|
class EventRendererTest {
|
||||||
|
|
||||||
|
private class FakeTerminal : CliTerminal {
|
||||||
|
override val prompt: String = ">"
|
||||||
|
val out = StringBuilder()
|
||||||
|
override suspend fun readLine(): String? = null
|
||||||
|
override suspend fun println(text: String) { out.appendLine(text) }
|
||||||
|
override suspend fun print(text: String) { out.append(text) }
|
||||||
|
override suspend fun printSystem(text: String) { out.appendLine("· $text") }
|
||||||
|
override fun close() {}
|
||||||
|
fun text() = out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `simple response stream`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет"))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!"))
|
||||||
|
r.render(Event.End(Instant.DISTANT_PAST))
|
||||||
|
|
||||||
|
// StartResponse открывает блок, AppendText без \n, End закрывает \n
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("Привет, мир!"), "got: $text")
|
||||||
|
// после End должен быть перевод строки
|
||||||
|
assertTrue(text.endsWith("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupted closes block`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
|
||||||
|
r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично"))
|
||||||
|
r.render(Event.Interrupted(Instant.DISTANT_PAST))
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("Частично"))
|
||||||
|
assertTrue(text.contains("· [interrupted]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `error before response`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500"))
|
||||||
|
val text = t.text()
|
||||||
|
assertTrue(text.contains("· [error/500] что-то сломалось"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start_reasoning is printed as system line`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartReasoning(Instant.DISTANT_PAST))
|
||||||
|
assertTrue(t.text().contains("· …thinking…"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image append renders placeholder`() = runTest {
|
||||||
|
val t = FakeTerminal()
|
||||||
|
val r = EventRenderer(t)
|
||||||
|
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE))
|
||||||
|
r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png"))
|
||||||
|
r.render(Event.End(Instant.DISTANT_PAST))
|
||||||
|
assertTrue(t.text().contains("[image:image/png:64 bytes]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SlashCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `help is parsed`() {
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("help"))
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("?"))
|
||||||
|
assertIs<SlashCommand.Help>(parseSlashOrNull("HELP"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exit and quit alias`() {
|
||||||
|
assertIs<SlashCommand.Exit>(parseSlashOrNull("exit"))
|
||||||
|
assertIs<SlashCommand.Quit>(parseSlashOrNull("q"))
|
||||||
|
assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new without title`() {
|
||||||
|
assertIs<SlashCommand.New>(parseSlashOrNull("new")).let {
|
||||||
|
assertEquals(null, it.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new with multi-word title`() {
|
||||||
|
val cmd = parseSlashOrNull("new my cool chat")
|
||||||
|
assertIs<SlashCommand.New>(cmd)
|
||||||
|
assertEquals("my cool chat", cmd.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switch requires id`() {
|
||||||
|
val r = parseSlash("sw")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switch with id`() {
|
||||||
|
val cmd = parseSlashOrNull("switch abc123")
|
||||||
|
assertIs<SlashCommand.Switch>(cmd)
|
||||||
|
assertEquals("abc123", cmd.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename requires title`() {
|
||||||
|
val r = parseSlash("rename")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
// А "rename " (с пробелом, но без слов после) — это уже успех с пустым title?
|
||||||
|
// У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename with title`() {
|
||||||
|
val cmd = parseSlashOrNull("rename my new title ")
|
||||||
|
assertIs<SlashCommand.Rename>(cmd)
|
||||||
|
assertEquals("my new title", cmd.title) // trim() делает своё
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete may have id or not`() {
|
||||||
|
assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let {
|
||||||
|
assertEquals(null, it.id)
|
||||||
|
}
|
||||||
|
assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let {
|
||||||
|
assertEquals("abc", it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown command fails`() {
|
||||||
|
val r = parseSlash("foobar")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty command fails`() {
|
||||||
|
val r = parseSlash("")
|
||||||
|
assertIs<ParseResult.Failure>(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `command is case insensitive`() {
|
||||||
|
assertIs<SlashCommand.List>(parseSlashOrNull("LIST"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP"))
|
||||||
|
assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt synonyms`() {
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop"))
|
||||||
|
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jline.reader.EndOfFileException
|
||||||
|
import org.jline.reader.LineReader
|
||||||
|
import org.jline.reader.LineReaderBuilder
|
||||||
|
import org.jline.reader.UserInterruptException
|
||||||
|
import org.jline.terminal.TerminalBuilder
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
actual object CliPlatform {
|
||||||
|
actual fun openAgent(baseUrl: String, id: String): Agent =
|
||||||
|
AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
|
|
||||||
|
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
|
||||||
|
JLineTerminal(historyFile = historyFile, prompt = prompt)
|
||||||
|
|
||||||
|
actual fun homeDir(): String? =
|
||||||
|
System.getenv("HOME") ?: System.getenv("USERPROFILE")
|
||||||
|
|
||||||
|
actual fun env(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
actual fun sessionIo(): SessionIo = JvmSessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`.
|
||||||
|
* tmp-файл пишется в той же директории, что и целевой, чтобы rename
|
||||||
|
* был атомарным в рамках одного раздела (POSIX rename(2) и Windows
|
||||||
|
* MoveFileEx — атомарны внутри одного тома).
|
||||||
|
*/
|
||||||
|
private object JvmSessionIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? {
|
||||||
|
val f = File(path)
|
||||||
|
if (!f.exists()) return null
|
||||||
|
return runCatching { f.readText() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeAtomic(path: String, body: String) {
|
||||||
|
val target = File(path)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(path + ".tmp")
|
||||||
|
tmp.writeText(body)
|
||||||
|
if (!tmp.renameTo(target)) {
|
||||||
|
// fallback: Windows-специфика — renameTo может не перезаписать существующий.
|
||||||
|
runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) }
|
||||||
|
.getOrElse { target.writeText(tmp.readText()); tmp.delete() }
|
||||||
|
}
|
||||||
|
} override fun delete(path: String) {
|
||||||
|
runCatching { File(path).delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [CliTerminal] поверх JLine ([LineReader]).
|
||||||
|
*
|
||||||
|
* JLine-3 API:
|
||||||
|
* - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY.
|
||||||
|
* - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E).
|
||||||
|
* - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем
|
||||||
|
* в [withContext] [Dispatchers.IO], чтобы не держать event-loop.
|
||||||
|
* - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла.
|
||||||
|
*/
|
||||||
|
private class JLineTerminal(
|
||||||
|
historyFile: String?,
|
||||||
|
override val prompt: String,
|
||||||
|
) : CliTerminal {
|
||||||
|
|
||||||
|
private val terminal = TerminalBuilder.builder()
|
||||||
|
.system(true)
|
||||||
|
.jna(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val historyImpl: org.jline.reader.History? = run {
|
||||||
|
if (historyFile == null) null else try {
|
||||||
|
val history = org.jline.reader.impl.history.DefaultHistory()
|
||||||
|
val histFile = File(historyFile)
|
||||||
|
histFile.parentFile?.mkdirs()
|
||||||
|
history.load()
|
||||||
|
if (histFile.exists()) {
|
||||||
|
history.append(histFile.toPath(), true)
|
||||||
|
}
|
||||||
|
history
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reader: LineReader = LineReaderBuilder.builder()
|
||||||
|
.terminal(terminal)
|
||||||
|
.apply { if (historyImpl != null) history(historyImpl) }
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val historyFilePath: java.nio.file.Path? =
|
||||||
|
historyFile?.let { File(it).toPath() }
|
||||||
|
|
||||||
|
override suspend fun readLine(): String? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val line = reader.readLine(prompt)
|
||||||
|
// Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C
|
||||||
|
// ничего не теряется.
|
||||||
|
flushHistory()
|
||||||
|
line
|
||||||
|
} catch (_: UserInterruptException) {
|
||||||
|
// Ctrl-C: трактуем как «всё, выходим», как и EOF.
|
||||||
|
flushHistory()
|
||||||
|
null
|
||||||
|
} catch (_: EndOfFileException) {
|
||||||
|
// Ctrl-D на пустой строке.
|
||||||
|
flushHistory()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().println(text)
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().print(text)
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
terminal.writer().println("· $text")
|
||||||
|
terminal.writer().flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushHistory() {
|
||||||
|
val hf = historyFilePath ?: return
|
||||||
|
val h = historyImpl ?: return
|
||||||
|
runCatching {
|
||||||
|
h.save()
|
||||||
|
if (!h.isEmpty) {
|
||||||
|
// читаем из .tmp и дописываем
|
||||||
|
h.append(hf, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { flushHistory() }
|
||||||
|
runCatching { terminal.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционный тест на реальном временном файле. Только JVM: использует
|
||||||
|
* [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается —
|
||||||
|
* TODO: переписать на kotlinx-io Files и перенести в commonTest.
|
||||||
|
*/
|
||||||
|
class SessionRepositoryTest {
|
||||||
|
|
||||||
|
private lateinit var tmp: File
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
tmp = File.createTempFile("agentik-cli-state", ".json")
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
if (tmp.exists()) tmp.delete()
|
||||||
|
File(tmp.path + ".tmp").delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load returns null when file missing`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save then load roundtrip`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
val savedAt = Instant.parse("2026-09-16T10:00:00Z")
|
||||||
|
repo.save(conversationId = "abcd-1234", lastEventAt = savedAt)
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
val repo2 = SessionRepository(tmp.path, JvmIo)
|
||||||
|
val restored = repo2.load()
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals("abcd-1234", restored.conversationId)
|
||||||
|
assertEquals(savedAt, restored.lastEventAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save overwrites previous state`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z"))
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
val restored = SessionRepository(tmp.path, JvmIo).load()
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals("conv-2", restored.conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null filepath means no-op`() {
|
||||||
|
val repo = SessionRepository(null, JvmIo)
|
||||||
|
repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
// Не должно ни читать, ни писать.
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear deletes file`() {
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z"))
|
||||||
|
repo.close()
|
||||||
|
assertTrue(tmp.exists())
|
||||||
|
|
||||||
|
val repo2 = SessionRepository(tmp.path, JvmIo)
|
||||||
|
repo2.clear()
|
||||||
|
assertTrue(!tmp.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `corrupt json is ignored (does not throw)`() {
|
||||||
|
File(tmp.path).writeText("this is not json")
|
||||||
|
val repo = SessionRepository(tmp.path, JvmIo)
|
||||||
|
assertNull(repo.load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста.
|
||||||
|
// В продакшен-коде на jvmMain ровно такая же логика.
|
||||||
|
private object JvmIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? {
|
||||||
|
val f = File(path); if (!f.exists()) return null
|
||||||
|
return runCatching { f.readText() }.getOrNull()
|
||||||
|
}
|
||||||
|
override fun writeAtomic(path: String, body: String) {
|
||||||
|
val target = File(path); target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(path + ".tmp")
|
||||||
|
tmp.writeText(body)
|
||||||
|
if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() }
|
||||||
|
}
|
||||||
|
override fun delete(path: String) { File(path).delete() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформо-зависимая реализация для native-целей.
|
||||||
|
*
|
||||||
|
* Текущий статус: stub. native HTTP требует подключения ktor-client-core +
|
||||||
|
* платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для
|
||||||
|
* linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования
|
||||||
|
* уже существующего `:client` SSE-парсера. Native readline требует termios
|
||||||
|
* через `kotlinx.cinterop` — добавим, когда дойдут руки.
|
||||||
|
*
|
||||||
|
* Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется
|
||||||
|
* под все 8 KMP-целей — структурная готовность соблюдена.
|
||||||
|
*/
|
||||||
|
actual object CliPlatform {
|
||||||
|
actual fun openAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
|
|
||||||
|
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
|
||||||
|
error("agentik-cli native target is not implemented yet (prompt=$prompt)")
|
||||||
|
|
||||||
|
actual fun homeDir(): String? = null
|
||||||
|
|
||||||
|
actual fun env(key: String): String? = null
|
||||||
|
|
||||||
|
actual fun sessionIo(): SessionIo = NoopSessionIo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */
|
||||||
|
private object NoopSessionIo : SessionIo {
|
||||||
|
override fun readAll(path: String): String? = null
|
||||||
|
override fun writeAtomic(path: String, body: String) {}
|
||||||
|
override fun delete(path: String) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Compose-style TUI-клиент в терминале на базе
|
||||||
|
[Mosaic](https://github.com/JakeWharton/mosaic) (Jetpack Compose
|
||||||
|
runtime, рендерится в ANSI-коды). Без `:`-команд (без vim-style
|
||||||
|
prompt): клавиатурная навигация Tab/Enter/Esc/Ctrl-D/F1/стрелки +
|
||||||
|
жирный focus indicator.
|
||||||
|
|
||||||
|
- **Layout**: header (id/conv/focus) + history + input + footer.
|
||||||
|
- **Focus**: Tab/Shift-Tab цикл по фокусам (input → history → sidebar).
|
||||||
|
- **Input**: стандартное текстовое поле с курсором `|` посередине.
|
||||||
|
- **Stream**: подписка на SSE в фон-корутинах, `StateFlow` + `collectAsState()`
|
||||||
|
для UI-реактивности (см. Snake sample).
|
||||||
|
|
||||||
|
Решает: полноценный TUI-клиент для тех, кто предпочитает мышкой
|
||||||
|
кликать в терминале больше, чем печатать. В отличие от `:agentik-cli`,
|
||||||
|
показывает историю диалога и текущий стрим в одном окне.
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- JVM 21+.
|
||||||
|
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
||||||
|
- Реальный TTY (через `ssh -tt`, `tmux`, либо нативный terminal).
|
||||||
|
|
||||||
|
### Запуск из готового fatjar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
|
-jar agentik-tui-0.1.0-all.jar \
|
||||||
|
--server http://192.168.76.166:8080/agentik
|
||||||
|
```
|
||||||
|
|
||||||
|
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
|
||||||
|
native syscalls для терминала.
|
||||||
|
|
||||||
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры CLI
|
||||||
|
|
||||||
|
| Флаг | ENV | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
||||||
|
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
||||||
|
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
||||||
|
| `--help` | — | Показывает help и выходит |
|
||||||
|
|
||||||
|
## Keybindings
|
||||||
|
|
||||||
|
| Клавиша | Когда | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `Tab` / `Shift-Tab` | глобально | Цикл фокусов: input → history → sidebar → ... |
|
||||||
|
| `F1` | глобально | Toggle help overlay |
|
||||||
|
| `Esc` | в input | Очистить input |
|
||||||
|
| `Enter` | в input | Submit message |
|
||||||
|
| `Backspace` / `Del` | в input | Удалить символ |
|
||||||
|
| `←` `→` `Home` `End` | в input | Курсор |
|
||||||
|
| `↑` `↓` | в history | Scrollback |
|
||||||
|
| `Ctrl-D` / `Ctrl-C` | — | Exit (TODO — пока работает только вне стрима) |
|
||||||
|
|
||||||
|
## Переменные среды (сервера)
|
||||||
|
|
||||||
|
См. [`../standalone/README.md`](../standalone/README.md). TUI
|
||||||
|
получает URL сервера через `--server`, остальное настройка
|
||||||
|
агента, а не клиента.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
|
||||||
|
же, что для `:agentik-cli`.
|
||||||
|
2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
|
||||||
|
built-in mouse-runtime). Планируется в v3 через termios
|
||||||
|
SGR-mouse.
|
||||||
|
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
|
||||||
|
собраны, но без `:client` (он JVM-only). Для нативной работы
|
||||||
|
нужен альтернативный HTTP-клиент.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :agentik-tui:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты composable'ов и event-рендеринга. Включает smoke-test для
|
||||||
|
key-event → AppState mutation → ре-рендер.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-tui`.
|
||||||
|
|
||||||
|
## Архитектурная заметка
|
||||||
|
|
||||||
|
UI-стейт держится в `StateFlow`, а **не** в Compose `mutableStateOf`.
|
||||||
|
Причина: Mosaic 0.18 не триггерит recomposition от `mutableStateOf`
|
||||||
|
-writes внутри `onPreviewKeyEvent`-handler'ов (см. Snake sample в
|
||||||
|
репо Mosaic — они тоже используют `StateFlow` + `collectAsState()`).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
// Suppress Beta-предупреждения от expect/actual объектов.
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
|
||||||
|
// iOS пропускаем — на iOS не бывает TUI-сессий.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||||
|
implementation(libs.mosaic.runtime)
|
||||||
|
implementation(libs.mosaic.tty.terminal)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(project(":client"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.tui.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fatjar (uberjar) ---
|
||||||
|
//
|
||||||
|
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
|
||||||
|
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
|
||||||
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
|
archiveBaseName.set("agentik-tui")
|
||||||
|
archiveClassifier.set("all")
|
||||||
|
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
from(tasks.named("jvmJar"))
|
||||||
|
val cc = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
}
|
||||||
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
mergeServiceFiles()
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-tui"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.layout.KeyEvent
|
||||||
|
import com.jakewharton.mosaic.layout.drawBehind
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Box
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Row
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая Compose-композиция TUI.
|
||||||
|
*
|
||||||
|
* Layout (минимальный):
|
||||||
|
* ```
|
||||||
|
* ┌─────────────────────────────────────────────────────────┐
|
||||||
|
* │ HEADER: agentik · id · conv-id · focus=… │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ HISTORY (весь актуальный диалог) │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ INPUT LINE: > text| │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
|
* └─────────────────────────────────────────────────────────┘
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun App(state: AppState) {
|
||||||
|
val focusIndex by state.focusIndex.collectAsState()
|
||||||
|
val showHelp by state.showHelp.collectAsState()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
|
||||||
|
when (ev.key) {
|
||||||
|
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
|
||||||
|
"F1" -> { state.toggleHelp(); true }
|
||||||
|
"Escape", "Esc" -> {
|
||||||
|
if (showHelp) state.setShowHelp(false)
|
||||||
|
else if (focusIndex == 0) state.inputClear()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Header(state, focusIndex)
|
||||||
|
HistoryPanel(state)
|
||||||
|
InputLine(state)
|
||||||
|
Footer(state, showHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showHelp) HelpOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(state: AppState, focusIndex: Int) {
|
||||||
|
val title by state.currentTitle.collectAsState()
|
||||||
|
val convId by state.currentConversationId.collectAsState()
|
||||||
|
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||||
|
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||||
|
val titleStr = title ?: "(нет диалога)"
|
||||||
|
Text(
|
||||||
|
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||||
|
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryPanel(state: AppState) {
|
||||||
|
val messages by state.messages.collectAsState()
|
||||||
|
val scroll by state.historyScroll.collectAsState()
|
||||||
|
val rendered = if (messages.isEmpty()) {
|
||||||
|
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||||
|
} else {
|
||||||
|
messages.joinToString("") { renderMessage(it) }
|
||||||
|
}
|
||||||
|
Text(value = rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderMessage(m: TuiMessage): String = when (m) {
|
||||||
|
is TuiMessage.System -> " ── ${m.text}\n"
|
||||||
|
is TuiMessage.User -> " > ${m.text}\n"
|
||||||
|
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||||
|
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||||
|
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||||
|
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InputLine(state: AppState) {
|
||||||
|
val text by state.input.collectAsState()
|
||||||
|
val cursor by state.cursor.collectAsState()
|
||||||
|
val streaming by state.streaming.collectAsState()
|
||||||
|
val cursorPos = cursor.coerceIn(0, text.length)
|
||||||
|
val before = text.substring(0, cursorPos)
|
||||||
|
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||||
|
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||||
|
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||||
|
val prompt = if (streaming) " ⋯" else " >"
|
||||||
|
|
||||||
|
Text(
|
||||||
|
value = "$prompt $before|$cursorChar|${after}",
|
||||||
|
modifier = Modifier
|
||||||
|
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||||
|
.drawBehind {
|
||||||
|
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||||
|
state.input.let { /* touch */ }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||||
|
if (ev.alt || ev.ctrl) return false
|
||||||
|
return when (ev.key) {
|
||||||
|
"Enter" -> state.submitInput() != null
|
||||||
|
"Backspace" -> { state.inputBackspace(); true }
|
||||||
|
"Delete" -> { state.inputDelete(); true }
|
||||||
|
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||||
|
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||||
|
"Home" -> { state.inputCursorHome(); true }
|
||||||
|
"End" -> { state.inputCursorEnd(); true }
|
||||||
|
else -> {
|
||||||
|
val s = ev.key
|
||||||
|
if (s.length == 1) { state.inputInsert(s); true }
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Footer(state: AppState, showHelp: Boolean) {
|
||||||
|
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||||
|
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||||
|
Text(value = hint, textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpOverlay() {
|
||||||
|
Column(modifier = Modifier) {
|
||||||
|
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||||
|
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||||
|
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||||
|
Text(value = " ← / → курсор в input")
|
||||||
|
Text(value = " Enter отправить сообщение")
|
||||||
|
Text(value = " Backspace / Del удалить символ")
|
||||||
|
Text(value = " Esc очистить input")
|
||||||
|
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||||
|
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние TUI. По дизайну — singleton, переживает все экраны.
|
||||||
|
*
|
||||||
|
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
|
||||||
|
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
|
||||||
|
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
|
||||||
|
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
|
||||||
|
* (см. samples/snake в репо Mosaic).
|
||||||
|
*/
|
||||||
|
internal class AppState(val config: TuiConfig) {
|
||||||
|
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||||
|
private val _focusIndex = MutableStateFlow(0)
|
||||||
|
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||||
|
|
||||||
|
/** Видимость help-оверлея. */
|
||||||
|
private val _showHelp = MutableStateFlow(false)
|
||||||
|
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
|
||||||
|
|
||||||
|
/** Сообщения диалога. */
|
||||||
|
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
/** Заголовок текущего диалога. */
|
||||||
|
private val _currentTitle = MutableStateFlow<String?>(null)
|
||||||
|
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
|
||||||
|
|
||||||
|
/** ID текущего диалога. */
|
||||||
|
private val _currentConversationId = MutableStateFlow<String?>(null)
|
||||||
|
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
|
||||||
|
|
||||||
|
/** Список диалогов (sidebar). */
|
||||||
|
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
|
||||||
|
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в списке диалогов. */
|
||||||
|
private val _conversationsCursor = MutableStateFlow(0)
|
||||||
|
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Поле ввода. */
|
||||||
|
private val _input = MutableStateFlow("")
|
||||||
|
val input: StateFlow<String> = _input.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в input (offset в chars). */
|
||||||
|
private val _cursor = MutableStateFlow(0)
|
||||||
|
val cursor: StateFlow<Int> = _cursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Идёт ли стрим. */
|
||||||
|
private val _streaming = MutableStateFlow(false)
|
||||||
|
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
|
||||||
|
|
||||||
|
/** Scrollback index: 0 = прижат к низу. */
|
||||||
|
private val _historyScroll = MutableStateFlow(0)
|
||||||
|
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
|
||||||
|
|
||||||
|
// ---------- мутации ----------
|
||||||
|
|
||||||
|
fun cycleFocus(direction: Int = +1) {
|
||||||
|
_focusIndex.value = (_focusIndex.value + direction).mod(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleHelp() { _showHelp.value = !_showHelp.value }
|
||||||
|
fun setShowHelp(v: Boolean) { _showHelp.value = v }
|
||||||
|
|
||||||
|
fun inputInsert(s: String) {
|
||||||
|
val pos = _cursor.value.coerceIn(0, _input.value.length)
|
||||||
|
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos + s.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputBackspace() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos <= 0) return
|
||||||
|
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputDelete() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos >= _input.value.length) return
|
||||||
|
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputClear() { _input.value = ""; _cursor.value = 0 }
|
||||||
|
|
||||||
|
fun inputMoveCursor(delta: Int) {
|
||||||
|
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
|
||||||
|
}
|
||||||
|
fun inputCursorHome() { _cursor.value = 0 }
|
||||||
|
fun inputCursorEnd() { _cursor.value = _input.value.length }
|
||||||
|
|
||||||
|
fun submitInput(): String? {
|
||||||
|
val text = _input.value.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||||
|
inputClear()
|
||||||
|
_streaming.value = true
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendAssistant(chunk: String) {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull()
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = last.copy(text = last.text + chunk)
|
||||||
|
} else {
|
||||||
|
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
|
||||||
|
}
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishAssistant() {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull() ?: return
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newConversation(id: String, title: String?) {
|
||||||
|
_currentConversationId.value = id
|
||||||
|
_currentTitle.value = title
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_historyScroll.value = 0
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postSystem(text: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимок диалога для sidebar. */
|
||||||
|
internal data class ConvSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Рендер-единица. */
|
||||||
|
internal sealed interface TuiMessage {
|
||||||
|
val ts: Instant
|
||||||
|
|
||||||
|
data class System(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class User(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа TUI-клиента agentik.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Без аргументов — стартует Compose-Mosaic UI.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args) ?: run {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
TuiApp(cfg).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
|
||||||
|
* Доступна из других файлов commonMain как `internal`.
|
||||||
|
*/
|
||||||
|
internal data class TuiConfig(
|
||||||
|
val server: String,
|
||||||
|
val id: String,
|
||||||
|
val historyEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCliArgs(args: Array<String>): TuiConfig? {
|
||||||
|
var server: String? = null
|
||||||
|
var id: String? = null
|
||||||
|
var historyEnabled = true
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
when (val a = args[i]) {
|
||||||
|
"--help", "-h", "help" -> return null
|
||||||
|
"--server", "-s" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует URL" }
|
||||||
|
server = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--id" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует значение" }
|
||||||
|
id = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--no-history" -> { historyEnabled = false; i++ }
|
||||||
|
"--" -> i++
|
||||||
|
else -> error("неизвестный аргумент: $a (введите --help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val envServer = platformEnv("AGENTIK_SERVER")
|
||||||
|
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
|
||||||
|
val resolvedId = id ?: "cli-tui:${envUser}"
|
||||||
|
|
||||||
|
return TuiConfig(
|
||||||
|
server = resolvedServer,
|
||||||
|
id = resolvedId,
|
||||||
|
historyEnabled = historyEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
|
||||||
|
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
|
||||||
|
*/
|
||||||
|
internal expect fun platformEnv(key: String): String?
|
||||||
|
|
||||||
|
private fun printUsage() {
|
||||||
|
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
|
||||||
|
println("""
|
||||||
|
agentik-tui — Compose-Mosaic UI поверх протокола agentik
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
agentik-tui [--server URL] [--id ID] [--no-history]
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
--server, -s URL базовый URL (default: $defaultServer)
|
||||||
|
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
|
||||||
|
--no-history не сохранять состояние
|
||||||
|
--help, -h эта справка
|
||||||
|
|
||||||
|
В UI:
|
||||||
|
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||||
|
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||||
|
← / → двинуть курсор в инпуте
|
||||||
|
Enter отправить сообщение
|
||||||
|
Ctrl-C / Ctrl-D выйти
|
||||||
|
F1 показать подсказки по горячим клавишам
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.jakewharton.mosaic.runMosaicBlocking
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
|
||||||
|
*
|
||||||
|
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
|
||||||
|
* одной Compose-композиции и пользуются её [CoroutineScope].
|
||||||
|
*
|
||||||
|
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
|
||||||
|
* стартует на пустом [Agent]-заглушке для smoke-теста.
|
||||||
|
*/
|
||||||
|
internal class TuiApp(private val config: TuiConfig) {
|
||||||
|
fun run() {
|
||||||
|
runMosaicBlocking {
|
||||||
|
val state = remember { AppState(config) }
|
||||||
|
App(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
|
||||||
|
*/
|
||||||
|
internal fun jvmCreateAgent(baseUrl: String, id: String): Agent = AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
|
||||||
|
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
|
||||||
|
* с понятной ошибкой.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = null
|
||||||
|
|
||||||
|
internal fun nativeCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
+80
-14
@@ -7,22 +7,78 @@ plugins {
|
|||||||
|
|
||||||
group = "pw.binom.agentik"
|
group = "pw.binom.agentik"
|
||||||
|
|
||||||
// Publication version: -Pversion=<tag> (CICD publishes by release tag).
|
// projectVersion определяется ниже как val, чтобы subprojects могли его
|
||||||
// Без явного -Pversion берётся fallback из gradle.properties или "0.1.0".
|
// прочитать через rootProject.extra["projectVersion"].
|
||||||
if (version == "unspecified") {
|
|
||||||
version = providers.gradleProperty("version").getOrElse("0.1.0")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Home Nexus URL/creds — Gitea action-variables BINOM_REPO_* (subochev/devops/publish).
|
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
||||||
// Локально для дебага: ./gradlew publish \
|
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
|
||||||
// -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
|
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
|
||||||
val binomRepoUrl = (findProperty("binom.repo.url") ?: "http://nexus.xx/repository/caffeine/").toString()
|
// 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 binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
|
||||||
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
|
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
|
||||||
|
|
||||||
|
// Per-module POM description. Один источник истины — карта ниже,
|
||||||
|
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
|
||||||
|
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
|
||||||
|
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
|
||||||
|
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
|
||||||
|
// beforeEvaluate — поздно).
|
||||||
|
val moduleDescriptions: Map<String, String> = mapOf(
|
||||||
|
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
|
||||||
|
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
|
||||||
|
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
|
||||||
|
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
|
||||||
|
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
|
||||||
|
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||||
|
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||||
|
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||||
|
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
||||||
|
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
||||||
|
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||||
|
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||||
|
"agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.",
|
||||||
|
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
||||||
|
)
|
||||||
|
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
group = rootProject.group
|
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")
|
apply(plugin = "maven-publish")
|
||||||
|
|
||||||
@@ -40,13 +96,23 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Per-subproject POM-метаданные (name, scm, licenses, developers).
|
// 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 {
|
publications.withType<MavenPublication>().configureEach {
|
||||||
|
groupId = rootProject.group.toString()
|
||||||
|
artifactId = project.name
|
||||||
|
version = rootProject.extra["projectVersion"] as String
|
||||||
|
|
||||||
pom {
|
pom {
|
||||||
name = project.name
|
name = project.name
|
||||||
description = providers.provider {
|
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
|
||||||
project.findProperty("description")?.toString()
|
?: "agentik module: ${project.name}"
|
||||||
?: "agentik: ${project.name} (pw.binom.agentik)"
|
|
||||||
}
|
|
||||||
url = "https://git.binom.pw/subochev/agentik"
|
url = "https://git.binom.pw/subochev/agentik"
|
||||||
|
|
||||||
licenses {
|
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-tui` — Compose-for-Mosaic клиент.
|
||||||
|
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets.commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ваш код:
|
||||||
|
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||||
|
val conv = agent.createConversation(title = "test")
|
||||||
|
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is Event.AppendText -> print(event.body)
|
||||||
|
is Event.End -> println("\n--- end ---")
|
||||||
|
is Event.Error -> error("agent error: ${event.message}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||||
|
|
||||||
|
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||||
|
|
||||||
|
## Примеры API
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// список диалогов
|
||||||
|
agent.getConversations().collect { println(it.id to it.title) }
|
||||||
|
|
||||||
|
// live-подписка на события отдельного диалога
|
||||||
|
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||||
|
|
||||||
|
// прерывание текущего хода
|
||||||
|
conversation.interrupt()
|
||||||
|
|
||||||
|
// история
|
||||||
|
conversation.getMessages(offset = 0).collect { msg ->
|
||||||
|
when (msg) {
|
||||||
|
is Message.UserMessage -> println("user: ${msg.content}")
|
||||||
|
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :client:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||||
|
401/404.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого LLM-кода. Это просто клиент.
|
||||||
|
- Никакого persistent state. История хранится у сервера, клиент её
|
||||||
|
запрашивает через `getMessages` или подписывается через `events`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||||
|
но клиент совместим с любым сервером, который держит wire-контракт
|
||||||
|
`:server`.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
|
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||||
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
@@ -15,8 +15,11 @@ import kotlin.time.Instant
|
|||||||
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
|
||||||
*/
|
*/
|
||||||
internal object InstantSerializer : KSerializer<Instant> {
|
internal object InstantSerializer : KSerializer<Instant> {
|
||||||
|
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
|
||||||
|
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
|
||||||
|
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
|
||||||
override val descriptor: SerialDescriptor =
|
override val descriptor: SerialDescriptor =
|
||||||
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
|
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: Instant) =
|
override fun serialize(encoder: Encoder, value: Instant) =
|
||||||
encoder.encodeString(value.toString())
|
encoder.encodeString(value.toString())
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,9 @@
|
|||||||
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD.
|
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
|
||||||
version=0.1.0
|
# Имя ключа специально НЕ '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.
|
# KMP jvm target uses JDK 21 for both compilation and toolchain.
|
||||||
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
|
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ jvector = "3.0.6"
|
|||||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
|
jline = "3.30.0"
|
||||||
|
mosaic = "0.18.0"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
|
||||||
|
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||||
|
|
||||||
@@ -56,6 +61,20 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
|||||||
# --- Model Context Protocol (MCP) ---
|
# --- Model Context Protocol (MCP) ---
|
||||||
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||||
|
|
||||||
|
# --- CLI: JLine (readline для JVM-таргета) ---
|
||||||
|
jline = { module = "org.jline:jline", version.ref = "jline" }
|
||||||
|
|
||||||
|
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||||
|
# https://github.com/JakeWharton/mosaic
|
||||||
|
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
|
||||||
|
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
|
||||||
|
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
|
|
||||||
# --- commons ---
|
# --- commons ---
|
||||||
|
|||||||
@@ -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)
|
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 {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
@@ -24,26 +34,14 @@ kotlin {
|
|||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.jvector)
|
implementation(libs.jvector)
|
||||||
implementation(libs.sqldelight.sqlite.driver)
|
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 {
|
jvmTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
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`, `:agentik-tui` — оба работают поверх `:client`,
|
||||||
|
а следовательно поверх `:proto`.
|
||||||
|
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
|
||||||
|
`Message`/`Event` напрямую через storage.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts
|
||||||
|
kotlin {
|
||||||
|
sourceSets.commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:proto:0.1.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакт `pw.binom.agentik:proto:0.1.0` живёт в Nexus-репозитории
|
||||||
|
`caffeine` (HTTP `http://<your-nexus>/repository/caffeine/`, plain-HTTP,
|
||||||
|
credentials — через переменные `binom.repo.user/password/url`).
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
Каталог `gradle/libs.versions.toml`, секция `[versions]` → `agentik-proto`.
|
||||||
|
Поднять версию → переопубликовать все KMP-таргеты через `./gradlew
|
||||||
|
:proto:publish -Pversion=...` (или триггернуть Gitea release).
|
||||||
|
|
||||||
|
Текущие KMP-таргеты: `jvm + macosX64/macosArm64 +
|
||||||
|
iosX64/iosArm64/iosSimulatorArm64 + linuxX64/linuxArm64 + mingwX64`.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
|
||||||
|
Настройки в `gradle.properties` / env: `binom.repo.url`, `binom.repo.user`,
|
||||||
|
`binom.repo.password`. `./gradlew :proto:publish` публикует все
|
||||||
|
target-specific артефакты + общий `kotlinMultiplatform`.
|
||||||
|
|
||||||
|
## Основные типы
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface Agent {
|
||||||
|
fun id: String
|
||||||
|
suspend fun createConversation(title: String? = null): Conversation
|
||||||
|
suspend fun getConversation(id: String): Conversation?
|
||||||
|
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
|
||||||
|
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
val updatedAt: Instant
|
||||||
|
val isSupportImageInput: Boolean
|
||||||
|
val isSupportImageOutput: Boolean
|
||||||
|
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
||||||
|
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
||||||
|
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
||||||
|
suspend fun rename(title: String): Boolean
|
||||||
|
fun interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Content {
|
||||||
|
class Text(val body: String) : Content
|
||||||
|
class Image(val data: ByteArray, val mime: String) : Content
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Message {
|
||||||
|
val id: String
|
||||||
|
val date: Instant
|
||||||
|
interface Body : Message { val content: List<Content> }
|
||||||
|
interface System : Message
|
||||||
|
class UserMessage(...) : Body
|
||||||
|
class AssistantMessage(...) : Body
|
||||||
|
class ToolCall(...) : System
|
||||||
|
class ToolResult(...) : System
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
enum ResponseType { TEXT, IMAGE }
|
||||||
|
class StartReasoning(...) : Event
|
||||||
|
class StartResponse(val type: ResponseType) : Event
|
||||||
|
class AppendText(val body: String) : Event
|
||||||
|
class AppendImage(val body: ByteArray, val mime: String) : Event
|
||||||
|
class End(...) : Event
|
||||||
|
class Interrupted(...) : Event
|
||||||
|
class Error(val message: String, val code: Int? = null) : Event
|
||||||
|
class ToolCall(...) : Event
|
||||||
|
class ToolResult(...) : Event
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||||
|
и `:client`.
|
||||||
|
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
||||||
|
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||||
|
внутри `:standalone` (ChatAgent) и выше.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :proto:jvmTest
|
||||||
|
./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Иммутабельный API (после рефакторинга из
|
||||||
|
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
|
||||||
|
contents, server-pushed references — все обсуждаются через общий
|
||||||
|
[IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
|
||||||
|
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
|
||||||
|
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
|
||||||
|
`GET /agentik/conversations`.
|
||||||
|
|
||||||
|
- **stateful** — сервер не принимает полную историю, только новые
|
||||||
|
сообщения. История хранится там, где развёрнут `Agent`.
|
||||||
|
- **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
|
||||||
|
обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
|
||||||
|
с snake_case-дискриминаторами).
|
||||||
|
|
||||||
|
Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
|
||||||
|
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает `Route.agentikAgent(agent)` в свой
|
||||||
|
embedded Netty engine.
|
||||||
|
- Любые клиенты (наши `:client`, `:agentik-cli`, `:agentik-tui`, или
|
||||||
|
внешние web-фронтенды) идут через этот контракт.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts (KMP JVM target)
|
||||||
|
plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
|
||||||
|
dependencies {
|
||||||
|
api("pw.binom.agentik:server:0.1.0")
|
||||||
|
api("pw.binom.agentik:proto:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ваш код:
|
||||||
|
fun Application.module(agent: Agent) {
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
install(SSE)
|
||||||
|
routing {
|
||||||
|
route("/agentik") { agentikAgent(agent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-server`.
|
||||||
|
|
||||||
|
## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
|
||||||
|
|
||||||
|
| Метод | Путь | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/conversations` | Создать диалог (body: `{title?}`) |
|
||||||
|
| `GET` | `/conversations` | Список диалогов (по `?offset=&limit=`) |
|
||||||
|
| `GET` | `/conversations/:id` | Снимок диалога + count |
|
||||||
|
| `GET` | `/conversations/:id/messages` | История сообщений (по `?after=`) |
|
||||||
|
| `POST` | `/conversations/:id/rename` | Переименовать (body: `{title}`) |
|
||||||
|
| `DELETE` | `/conversations/:id` | Удалить |
|
||||||
|
| `POST` | `/conversations/:id/send` | Send-флоу (body: `{content:[…]}` → SSE) |
|
||||||
|
| `GET` | `/conversations/:id/events` | Live подписка (SSE) |
|
||||||
|
| `POST` | `/conversations/:id/interrupt` | Прервать текущий `send()` |
|
||||||
|
|
||||||
|
`Content-Type: text/event-stream` всегда для SSE, ноль-лишних
|
||||||
|
заголовков. Сообщения: `event: <name>` (`message`, `start_reasoning`,
|
||||||
|
`start_response`, `append_text`, `append_image`, `end`, `interrupted`,
|
||||||
|
`error`) + `data: <JSON>`.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :server:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
|
||||||
|
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
|
||||||
|
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
|
||||||
|
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
|
||||||
|
добавляются только с snake_case-дискриминаторами и строго обратно
|
||||||
|
совместимо.
|
||||||
@@ -36,6 +36,12 @@ include(":skills")
|
|||||||
include(":server")
|
include(":server")
|
||||||
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
|
||||||
include(":client")
|
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")
|
||||||
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
||||||
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
||||||
include(":memory-api")
|
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 можно без поломок (новые поля игнорируются).
|
||||||
+123
-460
@@ -1,481 +1,144 @@
|
|||||||
# :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
|
```bash
|
||||||
# Полная сборка всего проекта + fatjar
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
./gradlew assemble
|
-jar agentik-0.1.0-all.jar
|
||||||
|
|
||||||
# Только fatjar :standalone (≈ 150 MB)
|
|
||||||
./gradlew :standalone:shadowJar
|
|
||||||
|
|
||||||
# Результат:
|
|
||||||
# standalone/build/libs/standalone-all.jar
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск
|
С дефолтами — встроенный SQLite, OpenAI-compatible backend на
|
||||||
|
`http://localhost:8001/v1`, порт 8080.
|
||||||
|
|
||||||
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar standalone/build/libs/standalone-all.jar
|
./gradlew :standalone:run
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию слушает на `http://localhost:8080`. Healthcheck: `GET /health`.
|
### `pull-model` subcommand (для LiteRT)
|
||||||
|
|
||||||
Транспорты на одном порту:
|
```bash
|
||||||
- `GET /health` — liveness
|
# Сначала скачать модель под LiteRT-Gemma-4-E2B
|
||||||
- `POST /agentik/conversations` — создать беседу
|
AGENTIK_LLM_BACKEND=google \
|
||||||
- `GET /agentik/conversations/{id}/events` — SSE-стрим ответов
|
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||||
- `POST /a2a/` — A2A JSON-RPC (`message/send`, `tasks/get`, `tasks/cancel`)
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
- `GET /a2a/.well-known/agent-card.json` — AgentCard
|
-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_PORT` | `8080` | Порт HTTP-сервера |
|
||||||
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite (история бесед + метаданные памяти) |
|
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
|
||||||
| `AGENTIK_SKILLS_DIR` | _выкл._ | Каталог со скилами (`SKILL.md` / `*.yaml`) |
|
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
|
||||||
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory` (md) или `AGENTIK_DB_PATH` (vector) | Каталог памяти (md); `"off"` отключает |
|
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
|
||||||
| `AGENTIK_MEMORY_BACKEND` | `md` | `md` (Hermes-style §-файлы) / `vector` (SQLite + JVector + LLM-эмбеддинги) / `off` |
|
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
|
||||||
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда (только HTTP) |
|
| `AGENTIK_LLM_API_URL` | `http://localhost:8001/v1` | Endpoint для OpenAI-compatible |
|
||||||
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (только HTTP; SIGLIP определяет автоматически) |
|
| `AGENTIK_LLM_API_KEY` | `no-key-needed` | Auth header |
|
||||||
| `AGENTIK_EMBEDDING_BACKEND` | `HTTP` | `HTTP` (POST /v1/embeddings) или `SIGLIP` (on-device, без сети) |
|
| `AGENTIK_LLM_CONTEXT_TOKENS` | `115000` | Сколько токенов остаётся модели |
|
||||||
| `AGENTIK_EMBEDDING_MODEL_PATH` | _только SIGLIP_ | Путь к `text_model_int8.onnx` (SigLIP2) |
|
| `AGENTIK_GOOGLE_MODEL_PATH` | `/root/gemma-4-E2B-it.litertlm` | Путь к `.litertlm` файлу |
|
||||||
| `AGENTIK_EMBEDDING_TOKENIZER_PATH` | _только SIGLIP_ | Путь к `tokenizer.model` (sentencepiece) |
|
| `AGENTIK_GOOGLE_MODEL_URL` | `https://static.binom.pw/models/gemma-4-E2B-it.litertlm` | Откуда скачивать |
|
||||||
| `AGENTIK_SOUL` | _выкл._ | Путь к `SOUL.md` — файл персоны (markdown), вставляется в начало system prompt |
|
| `AGENTIK_GOOGLE_MODEL_SHA256_URL` | — | Если задан — verify по SHA-256 |
|
||||||
| `AGENTIK_LLM_BACKEND` | — | `openai` или `google` (см. ниже) |
|
| `AGENTIK_AUTO_DOWNLOAD_MODEL` | `0` | `1` = скачать модель если её нет |
|
||||||
| `AGENTIK_MCP_CONFIG` | _выкл._ | Путь к JSON со списком MCP-серверов |
|
| `AGENTIK_MEMORY_BACKEND` | `vector` | `md`, `vector` или `off` |
|
||||||
| `AGENTIK_SYSTEM_PROMPT` | `be brief` | Базовый system prompt |
|
| `AGENTIK_EMBEDDING_BACKEND` | `http` | `http` или `siglip` (только для `vector`) |
|
||||||
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) |
|
| `AGENTIK_EMBEDDING_API_URL` | `http://localhost:8001/v1` | Endpoint для эмбеддингов |
|
||||||
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend |
|
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Имя embedding-модели |
|
||||||
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction |
|
| `AGENTIK_SOUL_PATH` | `~/.agentik/SOUL.md` | Путь к SOUL.md |
|
||||||
| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. |
|
| `AGENTIK_SKILLS_DIR` | `~/.agentik/skills/` | Каталог SKILL.md |
|
||||||
| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. |
|
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory/` | Каталог для md-памяти |
|
||||||
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Skill mining: через сколько user-ходов запускать фоновый прогон SkillMiner. `0` = выключено. |
|
| `AGENTIK_TOOLSETS_DEFAULT` | `memory,skills,files,web` | Включённые тулы |
|
||||||
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов передавать SkillMiner'у за один прогон. |
|
| `AGENTIK_DEBUG` | `0` | `1` = verbose logging |
|
||||||
| `AGENTIK_DEBUG_ENDPOINTS` | `0` | `1` включает debug-эндпоинты (`/debug/reflect`, `/debug/skill-mine`, `/debug/curate`, `/debug/compact`, `/debug/tokens`) |
|
|
||||||
|
Значения читаются через `AgentikConfig.fromEnv()` в `:standalone/.../Main.kt`.
|
||||||
### OpenAI backend
|
|
||||||
|
## Эндпоинты
|
||||||
| Переменная | Обязательна | Назначение |
|
|
||||||
|---|---|---|
|
| Метод | Путь | Transport | Описание |
|
||||||
| `OPENAI_BASE_URL` | да | Например, `https://api.openai.com/v1` |
|
|---|---|---|---|
|
||||||
| `OPENAI_API_KEY` | да | API key |
|
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
|
||||||
| `OPENAI_MODEL` | да | Имя модели (`gpt-4o-mini` и т.п.) |
|
| `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
|
||||||
|
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
|
||||||
### Google backend
|
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
|
||||||
|
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
|
||||||
| Переменная | Обязательна | Назначение |
|
| `GET` | `/agentik/conversations` | :proto | Список диалогов |
|
||||||
|---|---|---|
|
| `GET` | `/agentik/conversations/:id` | :proto | Snapshot |
|
||||||
| `AGENTIK_GOOGLE_MODEL_PATH` | да | Путь к `.litertlm` файлу |
|
| `GET` | `/agentik/conversations/:id/messages` | :proto | История |
|
||||||
|
| `POST` | `/agentik/conversations/:id/send` | :proto | Send (SSE) |
|
||||||
## SQLite: путь к базе диалогов
|
| `GET` | `/agentik/conversations/:id/events` | :proto | Live-events (SSE) |
|
||||||
|
| `POST` | `/agentik/conversations/:id/interrupt` | :proto | Прервать |
|
||||||
`AGENTIK_DB_PATH` указывает на файл SQLite, в котором хранятся таблицы
|
| `POST` | `/agentik/conversations/:id/rename` | :proto | Переименовать |
|
||||||
`conversations`, `messages`, `working_memory`. SQLDelight-драйвер создаёт
|
| `DELETE` | `/agentik/conversations/:id` | :proto | Удалить |
|
||||||
файл при первом запуске. Если в пути есть несуществующие директории — их
|
|
||||||
нужно создать заранее (`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).
|
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```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)
|
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 {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
@@ -46,7 +53,9 @@ kotlin {
|
|||||||
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
|
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
|
||||||
implementation(project(":memory-api"))
|
implementation(project(":memory-api"))
|
||||||
implementation(project(":memory-md"))
|
implementation(project(":memory-md"))
|
||||||
implementation(project(":memory-vector"))
|
if (!skipVectorMemory) {
|
||||||
|
implementation(project(":memory-vector"))
|
||||||
|
}
|
||||||
implementation(project(":storage-core"))
|
implementation(project(":storage-core"))
|
||||||
implementation(project(":storage-sqlite"))
|
implementation(project(":storage-sqlite"))
|
||||||
implementation(project(":agent-toolsets"))
|
implementation(project(":agent-toolsets"))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.agentik.standalone
|
package pw.binom.agentik.standalone
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
|
|||||||
import pw.binom.agentik.standalone.config.AgentikConfig
|
import pw.binom.agentik.standalone.config.AgentikConfig
|
||||||
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import pw.binom.agentik.standalone.llm.ModelDownloader
|
||||||
import pw.binom.agentik.standalone.mcp.McpRegistry
|
import pw.binom.agentik.standalone.mcp.McpRegistry
|
||||||
import pw.binom.agentik.storage.sqlite.SqliteStores
|
import pw.binom.agentik.storage.sqlite.SqliteStores
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -54,8 +56,143 @@ import java.io.File
|
|||||||
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
|
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
|
||||||
*/
|
*/
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
fun main() {
|
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 = AgentikConfig.fromEnv()
|
val config = AgentikConfig.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 = AgentikConfig.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 llm = config.llm.createLlm()
|
||||||
val storage = SqliteStores.open(dbPath = config.dbPath).asBundle()
|
val storage = SqliteStores.open(dbPath = config.dbPath).asBundle()
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ class ChatAgent(
|
|||||||
private val toolsets: List<ToolsetContribution> = emptyList(),
|
private val toolsets: List<ToolsetContribution> = emptyList(),
|
||||||
) : ProtoAgent, AutoCloseable {
|
) : 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).
|
* Реестр активных тулсетов — один на агента (per-agent state).
|
||||||
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
|
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
|
||||||
@@ -164,7 +174,7 @@ class ChatAgent(
|
|||||||
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
|
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 +222,6 @@ class ChatAgent(
|
|||||||
if (!temp) {
|
if (!temp) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
storage.conversationStore.upsert(rec)
|
storage.conversationStore.upsert(rec)
|
||||||
storage.workingMemoryStore.append(
|
|
||||||
conversationId = id,
|
|
||||||
entry = WorkingMemoryEntry.System(text = systemPrompt),
|
|
||||||
now = now,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val conv = ChatConversation(
|
val conv = ChatConversation(
|
||||||
|
|||||||
+399
-141
@@ -2,11 +2,15 @@ package pw.binom.agentik.standalone.agent
|
|||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -15,6 +19,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
@@ -155,7 +161,16 @@ class ChatConversation(
|
|||||||
private val messageStore: MessageStore get() = storage.messageStore
|
private val messageStore: MessageStore get() = storage.messageStore
|
||||||
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
|
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
|
||||||
|
|
||||||
private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name }
|
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: регистрирует дополнительный tool в [toolsByName] ПОСЛЕ создания
|
||||||
|
* ChatConversation. Используется в тестах `interrupt mid-tool` для симуляции
|
||||||
|
* долгого tool-вызова, который можно прервать через interrupt().
|
||||||
|
*/
|
||||||
|
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
|
||||||
|
toolsByName[name] = NamedTool(name = name, tool = tool)
|
||||||
|
}
|
||||||
|
|
||||||
private val events = MutableSharedFlow<ProtoEvent>(
|
private val events = MutableSharedFlow<ProtoEvent>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
@@ -164,7 +179,15 @@ class ChatConversation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val turnLock = Mutex()
|
private val turnLock = Mutex()
|
||||||
|
// Основной scope для активного turn'а — IO (много потоков, не упираемся).
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
// Фоновые задачи (review/reflection/skill-mining) — отдельный pool с
|
||||||
|
// bounded parallelism. Они делают sync LLM-вызовы через runBlocking внутри
|
||||||
|
// LiteLlm.send() — если запустить 30+ параллельно (по одному на беседу),
|
||||||
|
// упираемся в IO-thread starvation и все повисают в очереди.
|
||||||
|
private val backgroundScope = CoroutineScope(
|
||||||
|
SupervisorJob() + Dispatchers.IO.limitedParallelism(4),
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var liteConv: LiteConversation? = null
|
private var liteConv: LiteConversation? = null
|
||||||
@@ -173,6 +196,23 @@ class ChatConversation(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var closed = false
|
private var closed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Флаг прерывания текущего turn'а. Ставится в `true` через [interrupt].
|
||||||
|
* Проверяется в [runTurn] на каждой итерации tool-loop и в finally-блоке —
|
||||||
|
* влияет на то, какие финальные события эмитятся и какие записи в working
|
||||||
|
* memory создаются. Идемпотентен: повторные interrupt() после первого —
|
||||||
|
* no-op.
|
||||||
|
*/
|
||||||
|
private val interrupted = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текущий выполняющийся tool-call (sub-Job в нашем scope). Ставится в
|
||||||
|
* [runToolAndPersist] перед `tool.tool.invoke()` и зануляется в finally.
|
||||||
|
* `interrupt()` делает `currentToolJob?.cancel()` чтобы отменить
|
||||||
|
* конкретно тулл, не убивая весь activeTurn.
|
||||||
|
*/
|
||||||
|
private var currentToolJob: Job? = null
|
||||||
|
|
||||||
internal val isClosed: Boolean get() = closed
|
internal val isClosed: Boolean get() = closed
|
||||||
|
|
||||||
override suspend fun rename(title: String) {
|
override suspend fun rename(title: String) {
|
||||||
@@ -217,10 +257,29 @@ class ChatConversation(
|
|||||||
activeTurn?.join()
|
activeTurn?.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun interrupt() {
|
override suspend fun interrupt() {
|
||||||
|
// Сигнал, а не убийство:
|
||||||
|
// 1. Ставим флаг — runTurn увидит его в finally-блоке и в tool-loop,
|
||||||
|
// эмитит `Interrupted` event + корректно закроет LiteConv.
|
||||||
|
// 2. Отменяем in-flight tool-job (если есть) — кооперативная отмена
|
||||||
|
// через CancellationException внутри `tool.tool.invoke`.
|
||||||
|
// 3. Отменяем генерацию в LiteRT-LM (`cancelProcess`) — стрим
|
||||||
|
// `sendStreamContents` бросит CancellationException.
|
||||||
|
// activeTurn НЕ cancel — даём runTurn'у finally-блоку корректно
|
||||||
|
// записать state (частичный assistant text + cancelled tool exchanges)
|
||||||
|
// и эмитить End. close() тоже не вызываем — это сделает finally.
|
||||||
|
//
|
||||||
|
// NB: ставим флаг ТОЛЬКО если есть что прерывать. Если вызвали interrupt()
|
||||||
|
// "в пустоту" (нет активного turn'а), флаг не должен отравлять следующий
|
||||||
|
// send — иначе runTurn'у следующего хода сразу придётся short-circuit'нуть,
|
||||||
|
// и пользователь не получит ответа на свой "Ок." после явного cancel.
|
||||||
|
if (activeTurn?.isActive != true) {
|
||||||
|
log.info { "interrupt() no-op: no active turn for $id" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interrupted.set(true)
|
||||||
runCatching { liteConv?.cancel() }
|
runCatching { liteConv?.cancel() }
|
||||||
activeTurn?.cancel()
|
currentToolJob?.cancel()
|
||||||
emitEvent(ProtoEvent.Interrupted(date = now()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<ProtoEvent> =
|
override fun events(after: Instant): Flow<ProtoEvent> =
|
||||||
@@ -249,8 +308,17 @@ class ChatConversation(
|
|||||||
* В отличие от старого "void addToolResult + sendStreamContents(" ")" — здесь
|
* В отличие от старого "void addToolResult + sendStreamContents(" ")" — здесь
|
||||||
* нет фантомного trigger-сообщения: LiteDelta из addToolResult несёт и текст
|
* нет фантомного trigger-сообщения: LiteDelta из addToolResult несёт и текст
|
||||||
* и nested tool-calls, и мы их тут же обрабатываем.
|
* и nested tool-calls, и мы их тут же обрабатываем.
|
||||||
|
*
|
||||||
|
* **Interrupt-safe.** Весь turn обёрнут в `try/finally` — даже при отмене
|
||||||
|
* [interrupt] (CancellationException через LiteConv.cancel()) мы записываем
|
||||||
|
* накопленное состояние (частичный assistant text + все tool-exchanges с
|
||||||
|
* маркером `[cancelled by user]` для прерванных) и эмитим `Interrupted`
|
||||||
|
* event перед `End`. LiteConv закрывается в finally — следующий `send()`
|
||||||
|
* создаст новую LiteConv через `getOrCreateLiteConversation` с честной
|
||||||
|
* историей из working memory.
|
||||||
*/
|
*/
|
||||||
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
|
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
|
||||||
|
val wasInterruptedAtEntry = interrupted.get()
|
||||||
if (!record.isTemporal) {
|
if (!record.isTemporal) {
|
||||||
compactPreTurnIfNeeded()
|
compactPreTurnIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -281,7 +349,7 @@ class ChatConversation(
|
|||||||
addAll(parts)
|
addAll(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
val liteConv = try {
|
val conv = try {
|
||||||
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
|
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
this.liteConv = null
|
this.liteConv = null
|
||||||
@@ -290,6 +358,7 @@ class ChatConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val reply = StringBuilder()
|
val reply = StringBuilder()
|
||||||
|
val toolExchanges = mutableListOf<WorkingMemoryEntry.ToolExchange>()
|
||||||
var currentParts: List<LiteContentPart> = initialParts
|
var currentParts: List<LiteContentPart> = initialParts
|
||||||
var loopGuard = 0
|
var loopGuard = 0
|
||||||
|
|
||||||
@@ -299,115 +368,251 @@ class ChatConversation(
|
|||||||
// даёт нам `output` (то что добавила модель: assistant text + tool
|
// даёт нам `output` (то что добавила модель: assistant text + tool
|
||||||
// call args + tool results, естественно накопленные за tool loop).
|
// call args + tool results, естественно накопленные за tool loop).
|
||||||
// Если tokenCount() не поддерживается бэкендом или кидает — tokens останется null.
|
// Если tokenCount() не поддерживается бэкендом или кидает — tokens останется null.
|
||||||
val tokensAtTurnStart: Int? = readTokenCount(liteConv)
|
val tokensAtTurnStart: Int? = readTokenCount(conv)
|
||||||
var turnTokens: TurnTokens? = null
|
var turnTokens: TurnTokens? = null
|
||||||
|
|
||||||
var pendingParts: List<LiteContentPart>? = currentParts
|
var pendingParts: List<LiteContentPart>? = currentParts
|
||||||
while (loopGuard++ < MAX_TOOL_LOOPS) {
|
try {
|
||||||
// 1) Initial user message: send full text, model may respond with
|
// Если interrupt() пришёл ДО старта turn'а — не дёргаем LLM вообще.
|
||||||
// text + toolCalls. Subsequent iterations: pendingParts = null →
|
// В finally пишем Interruption/End; assistant skipped потому что ничего
|
||||||
// skip send, drive via addToolResult loop below.
|
// не было сгенерировано.
|
||||||
val collectedCalls = mutableListOf<LiteToolCall>()
|
if (wasInterruptedAtEntry) {
|
||||||
if (pendingParts != null) {
|
log.info { "runTurn short-circuit on interrupted-flag-at-entry: $id" }
|
||||||
try {
|
return
|
||||||
liteConv.sendStreamContents(pendingParts!!).collect { delta ->
|
|
||||||
if (delta.text.isNotEmpty()) {
|
|
||||||
reply.append(delta.text)
|
|
||||||
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
|
||||||
}
|
|
||||||
if (delta.toolCalls.isNotEmpty()) {
|
|
||||||
collectedCalls.addAll(delta.toolCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
this.liteConv = null
|
|
||||||
failTurn(e.message ?: e.javaClass.simpleName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingParts = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Tool-loop: process collected tool calls. After each tool, feed
|
// Накапливаем tool_calls из post-tool continuation'ов (вызов
|
||||||
// the result back via addToolResult (returns LiteDelta — text +
|
// sendStreamContents после addToolResult нужен для stateless-бэкендов).
|
||||||
// possibly nested toolCalls). Cycle exits when model no longer
|
// Эти вызовы обрабатываются на СЛЕДУЮЩЕЙ итерации outer-while, чтобы
|
||||||
// requests tools.
|
// избежать бесконечной вложенности в случае моделей/fake'ов, которые
|
||||||
var nextCalls = collectedCalls
|
// всегда возвращают tool_calls.
|
||||||
while (nextCalls.isNotEmpty()) {
|
var pendingPostToolCalls: List<LiteToolCall> = emptyList()
|
||||||
val prev = nextCalls
|
|
||||||
nextCalls = mutableListOf()
|
while (loopGuard++ < MAX_TOOL_LOOPS) {
|
||||||
for (call in prev) {
|
// 0) Если interrupt случился до старта sendStreamContents (например во время
|
||||||
val (callId, resultText) = runToolAndPersist(call)
|
// compactPreTurn) — нет ни текста, ни тулов. Просто выходим,
|
||||||
val delta = try {
|
// finally-блок запишет минимальный state и эмит Interrupted.
|
||||||
liteConv.addToolResult(callId = callId, name = call.name, result = resultText)
|
if (interrupted.get() && pendingParts == null) break
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
throw e
|
// 1) Initial user message: send full text, model may respond with
|
||||||
|
// text + toolCalls. Subsequent iterations: pendingParts = null →
|
||||||
|
// skip send, drive via addToolResult loop below.
|
||||||
|
val collectedCalls = mutableListOf<LiteToolCall>()
|
||||||
|
if (pendingParts != null) {
|
||||||
|
try {
|
||||||
|
liteConv!!.sendStreamContents(pendingParts!!).collect { delta ->
|
||||||
|
if (delta.text.isNotEmpty()) {
|
||||||
|
reply.append(delta.text)
|
||||||
|
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
||||||
|
}
|
||||||
|
if (delta.toolCalls.isNotEmpty()) {
|
||||||
|
collectedCalls.addAll(delta.toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// LiteConv был отменён через interrupt() — это нормальный flow.
|
||||||
|
// Выходим из while, finally-блок запишет state.
|
||||||
|
log.info { "sendStreamContents cancelled for $id" }
|
||||||
|
break
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
this.liteConv = null
|
this.liteConv = null
|
||||||
failTurn(e.message ?: e.javaClass.simpleName)
|
failTurn(e.message ?: e.javaClass.simpleName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (delta.text.isNotEmpty()) {
|
pendingParts = null
|
||||||
reply.append(delta.text)
|
}
|
||||||
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
|
||||||
|
// 2) Tool-loop: process collected tool calls. After each tool, feed
|
||||||
|
// the result back via addToolResult (returns LiteDelta — text +
|
||||||
|
// possibly nested toolCalls). Cycle exits when model no longer
|
||||||
|
// requests tools.
|
||||||
|
// pendingPostToolCalls (с предыдущей итерации outer-loop'а) обрабатываем
|
||||||
|
// первыми — если stateless-бэкенд вернул tool_calls в continuation,
|
||||||
|
// их надо прогнать через tool-loop, прежде чем считать turn завершённым.
|
||||||
|
var nextCalls = if (pendingPostToolCalls.isNotEmpty()) pendingPostToolCalls else collectedCalls
|
||||||
|
pendingPostToolCalls = emptyList()
|
||||||
|
while (nextCalls.isNotEmpty()) {
|
||||||
|
val prev = nextCalls
|
||||||
|
nextCalls = mutableListOf()
|
||||||
|
for (call in prev) {
|
||||||
|
val exchange = runToolAndPersist(call)
|
||||||
|
toolExchanges += exchange
|
||||||
|
// addToolResult — синхронный вызов, тоже может быть отменён
|
||||||
|
// через LiteConv.cancel() (например при interrupt в середине
|
||||||
|
// tool-loop'а). В этом случае break из внутреннего while —
|
||||||
|
// finally сохранит уже накопленные exchanges.
|
||||||
|
val delta = try {
|
||||||
|
liteConv!!.addToolResult(callId = exchange.sourceMessageId, name = exchange.toolName, result = exchange.resultText)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
log.info { "addToolResult cancelled for $id" }
|
||||||
|
break
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
this.liteConv = null
|
||||||
|
failTurn(e.message ?: e.javaClass.simpleName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (delta.text.isNotEmpty()) {
|
||||||
|
reply.append(delta.text)
|
||||||
|
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
|
||||||
|
}
|
||||||
|
if (delta.toolCalls.isNotEmpty()) {
|
||||||
|
nextCalls.addAll(delta.toolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// После addToolResult вызываем sendStreamContents с пустым
|
||||||
|
// контентом — это триггерит следующий ответ модели после
|
||||||
|
// tool-result'а. Нужно для stateless-бэкендов (OpenAI):
|
||||||
|
// addToolResult у них только дописывает в history, реальный
|
||||||
|
// ответ приходит только при следующем send. Для stateful
|
||||||
|
// бэкендов (Google LiteRT-LM) addToolResult сам запускает
|
||||||
|
// генерацию — повторный send будет пустым ответом (isDone),
|
||||||
|
// collect() просто пропускает.
|
||||||
|
//
|
||||||
|
// Если бы мы этого не делали — OpenAI-бэкенд возвращал
|
||||||
|
// бы только tool_call → tool_result → пустой assistant,
|
||||||
|
// без финального текста после tool'а.
|
||||||
|
if (!interrupted.get()) {
|
||||||
|
try {
|
||||||
|
// Нельзя передавать emptyList() — LiteMessage требует
|
||||||
|
// непустой contents. Используем невидимый placeholder
|
||||||
|
// (пробел) — OpenAI-бэкенд допишет его как user-message
|
||||||
|
// и триггерит ответ модели. На стороне LiteRT-LM
|
||||||
|
// (stateful) addToolResult уже выполнил работу, так
|
||||||
|
// что ответ будет пустой/короткий и мы просто
|
||||||
|
// проигнорируем его в collect.
|
||||||
|
//
|
||||||
|
// NB: собираем ТОЛЬКО text из ответа. tool_calls из
|
||||||
|
// post-tool continuation добавляются в отдельный буфер
|
||||||
|
// outer-loop'а — иначе можно попасть в бесконечный
|
||||||
|
// tool-loop (тестовая fake-LiteLlm, например, всегда
|
||||||
|
// возвращает tool_call из sendStreamContents).
|
||||||
|
val collectedPostTool = mutableListOf<LiteToolCall>()
|
||||||
|
liteConv!!.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
||||||
|
if (followUp.text.isNotEmpty()) {
|
||||||
|
reply.append(followUp.text)
|
||||||
|
emitEvent(ProtoEvent.AppendText(date = now(), body = followUp.text))
|
||||||
|
}
|
||||||
|
if (followUp.toolCalls.isNotEmpty()) {
|
||||||
|
collectedPostTool.addAll(followUp.toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Обрабатываем tool_calls из continuation в outer-loop
|
||||||
|
// (следующая итерация while), а не в этом же inner-while.
|
||||||
|
// Устанавливаем флаг, чтобы вернуться к outer.
|
||||||
|
if (collectedPostTool.isNotEmpty()) {
|
||||||
|
// Передаём в outer-loop: добавляем в pendingCallsForNextIter
|
||||||
|
pendingPostToolCalls = collectedPostTool
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
log.info { "post-tool sendStreamContents cancelled for $id" }
|
||||||
|
break
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.warn(e) { "post-tool sendStreamContents failed for $id" }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (delta.toolCalls.isNotEmpty()) {
|
if (interrupted.get()) break
|
||||||
nextCalls.addAll(delta.toolCalls)
|
}
|
||||||
|
|
||||||
|
if (nextCalls.isEmpty() && pendingParts == null) break
|
||||||
|
if (interrupted.get()) break
|
||||||
|
// (pendingParts != null случай обработан выше; сюда попадём только
|
||||||
|
// если executeToolCall сам породил вложенный tool-loop и мы хотим
|
||||||
|
// продолжить — но мы это уже разрулили внутренним while выше.)
|
||||||
|
if (nextCalls.isEmpty()) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopGuard >= MAX_TOOL_LOOPS) {
|
||||||
|
log.warn { "tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Считаем дельту после цикла (defensive: turnTokens может остаться null).
|
||||||
|
if (tokensAtTurnStart != null) {
|
||||||
|
val tokensAtTurnEnd = readTokenCount(conv!!)
|
||||||
|
if (tokensAtTurnEnd != null) {
|
||||||
|
val output = (tokensAtTurnEnd - tokensAtTurnStart).coerceAtLeast(0)
|
||||||
|
turnTokens = TurnTokens(input = tokensAtTurnStart, output = output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Закрываем LiteConv в любом случае: при interrupt следующий send
|
||||||
|
// получит свежую LiteConv с initialMessages из working memory.
|
||||||
|
runCatching { liteConv?.close() }
|
||||||
|
this.liteConv = null
|
||||||
|
|
||||||
|
// Если была отмена в самом начале turn'а (например до старта sendStreamContents)
|
||||||
|
// и runTurn вышел через ранний return — wasInterruptedAtEntry = true,
|
||||||
|
// interrupted.get() = true. Если же мы просто успешно отработали —
|
||||||
|
// interrupted.get() = false (флаг сбрасывается в конце, после emit).
|
||||||
|
val wasInterrupted = interrupted.get()
|
||||||
|
|
||||||
|
if (!record.isTemporal) {
|
||||||
|
// В audit log пишем AssistantMessage ТОЛЬКО если turn что-то произвёл
|
||||||
|
// (текст или tool-exchanges). На чистом прерывании/ошибке ДО первого
|
||||||
|
// sendStreamContents — пустой Assistant был бы мусором (тест LLM-failure
|
||||||
|
// ожидает именно [user, error] без пустого assistant).
|
||||||
|
if (reply.isNotEmpty() || toolExchanges.isNotEmpty()) {
|
||||||
|
val assistantId = newId("msg")
|
||||||
|
val assistantAt = now()
|
||||||
|
val assistantContent = listOf(Content.Text(reply.toString()))
|
||||||
|
|
||||||
|
val assistantRecord = MessageRecord.AssistantMessage(
|
||||||
|
id = assistantId,
|
||||||
|
conversationId = id,
|
||||||
|
content = assistantContent,
|
||||||
|
createdAt = assistantAt,
|
||||||
|
tokens = turnTokens,
|
||||||
|
)
|
||||||
|
messageStore.append(assistantRecord)
|
||||||
|
|
||||||
|
// В working_memory пишем Assistant-message — LLM видит его
|
||||||
|
// как model-role initialMessages при следующем send().
|
||||||
|
workingMemory.append(
|
||||||
|
conversationId = id,
|
||||||
|
entry = WorkingMemoryEntry.Assistant(
|
||||||
|
sourceMessageId = assistantId,
|
||||||
|
content = assistantContent,
|
||||||
|
),
|
||||||
|
now = assistantAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Каждый tool-exchange одной строкой в working memory — для
|
||||||
|
// replay'а в LiteMessage(TOOL, ToolResult) при пересоздании LiteConv.
|
||||||
|
for (ex in toolExchanges) {
|
||||||
|
workingMemory.append(
|
||||||
|
conversationId = id,
|
||||||
|
entry = ex,
|
||||||
|
now = assistantAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record = record.copy(updatedAt = assistantAt)
|
||||||
|
conversationStore.touch(id, assistantAt)
|
||||||
|
|
||||||
|
scheduleReview(userRecord, assistantContent)
|
||||||
|
scheduleReflection(userRecord, assistantContent)
|
||||||
|
scheduleSkillMining(userRecord, assistantContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextCalls.isEmpty() && pendingParts == null) break
|
// Interrupted event — клиент видит его в SSE сразу как прерывание
|
||||||
// (pendingParts != null случай обработан выше; сюда попадём только
|
// произошло (на самом деле он эмитится в finally, после возможного
|
||||||
// если executeToolCall сам породил вложенный tool-loop и мы хотим
|
// финального ответа модели — это нормально, клиент рендерит оба).
|
||||||
// продолжить — но мы это уже разрулили внутренним while выше.)
|
//
|
||||||
if (nextCalls.isEmpty()) break
|
// Если interrupt() пришёл ПОСЛЕ того, как мы прочитали wasInterrupted
|
||||||
}
|
// в начале finally — перечитываем флаг здесь, чтобы корректно эмитить
|
||||||
|
// Interrupted event и сбрасывать флаг после.
|
||||||
if (loopGuard >= MAX_TOOL_LOOPS) {
|
if (wasInterrupted || interrupted.get()) {
|
||||||
log.warn { "tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing" }
|
emitEvent(ProtoEvent.Interrupted(date = now()))
|
||||||
}
|
|
||||||
|
|
||||||
// Считаем дельту после цикла (defensive: turnTokens может остаться null).
|
|
||||||
if (tokensAtTurnStart != null) {
|
|
||||||
val tokensAtTurnEnd = readTokenCount(liteConv)
|
|
||||||
if (tokensAtTurnEnd != null) {
|
|
||||||
val output = (tokensAtTurnEnd - tokensAtTurnStart).coerceAtLeast(0)
|
|
||||||
turnTokens = TurnTokens(input = tokensAtTurnStart, output = output)
|
|
||||||
}
|
}
|
||||||
|
emitEvent(ProtoEvent.End(date = now()))
|
||||||
|
|
||||||
|
// Сбрасываем флаг — следующий turn стартует чистым. Всегда
|
||||||
|
// (compareAndSet атомарен, защищаем от race-condition: interrupt()
|
||||||
|
// мог быть вызван между чтением wasInterrupted и этой строкой).
|
||||||
|
interrupted.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
val assistantId = newId("msg")
|
|
||||||
val assistantAt = now()
|
|
||||||
val assistantContent = listOf(Content.Text(reply.toString()))
|
|
||||||
val assistantRecord = MessageRecord.AssistantMessage(
|
|
||||||
id = assistantId,
|
|
||||||
conversationId = id,
|
|
||||||
content = assistantContent,
|
|
||||||
createdAt = assistantAt,
|
|
||||||
tokens = turnTokens,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!record.isTemporal) {
|
|
||||||
messageStore.append(assistantRecord)
|
|
||||||
workingMemory.append(
|
|
||||||
conversationId = id,
|
|
||||||
entry = WorkingMemoryEntry.Assistant(
|
|
||||||
sourceMessageId = assistantId,
|
|
||||||
content = assistantContent,
|
|
||||||
),
|
|
||||||
now = assistantAt,
|
|
||||||
)
|
|
||||||
record = record.copy(updatedAt = assistantAt)
|
|
||||||
conversationStore.touch(id, assistantAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleReview(userRecord, assistantContent)
|
|
||||||
scheduleReflection(userRecord, assistantContent)
|
|
||||||
scheduleSkillMining(userRecord, assistantContent)
|
|
||||||
|
|
||||||
emitEvent(ProtoEvent.End(date = assistantAt))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -473,12 +678,12 @@ class ChatConversation(
|
|||||||
private suspend fun compactPreTurn(force: Boolean): Boolean {
|
private suspend fun compactPreTurn(force: Boolean): Boolean {
|
||||||
val window = contextWindow ?: return false
|
val window = contextWindow ?: return false
|
||||||
val compactor = contextCompactor ?: return false
|
val compactor = contextCompactor ?: return false
|
||||||
|
// System prompt живёт только in-memory в ChatConversation.systemPrompt —
|
||||||
|
// он не участвует ни в working_memory, ни в compaction.
|
||||||
val wm = workingMemory.list(id)
|
val wm = workingMemory.list(id)
|
||||||
if (wm.isEmpty()) return false
|
if (wm.isEmpty()) return false
|
||||||
|
|
||||||
val systemText = wm.firstOrNull { it.entry is WorkingMemoryEntry.System }
|
val systemText = systemPrompt
|
||||||
?.let { (it.entry as WorkingMemoryEntry.System).text }
|
|
||||||
?: systemPrompt
|
|
||||||
val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant }
|
val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant }
|
||||||
val toolsChars = tools.sumOf { it.tool.describe().length }
|
val toolsChars = tools.sumOf { it.tool.describe().length }
|
||||||
|
|
||||||
@@ -637,7 +842,7 @@ class ChatConversation(
|
|||||||
.joinToString("\n") { it.body }
|
.joinToString("\n") { it.body }
|
||||||
if (userText.isBlank() || assistantText.isBlank()) return
|
if (userText.isBlank() || assistantText.isBlank()) return
|
||||||
val convId = id
|
val convId = id
|
||||||
scope.launch {
|
backgroundScope.launch {
|
||||||
try {
|
try {
|
||||||
val decision: MemoryReviewDecision = reviewer.review(
|
val decision: MemoryReviewDecision = reviewer.review(
|
||||||
ReviewedTurn(
|
ReviewedTurn(
|
||||||
@@ -685,7 +890,7 @@ class ChatConversation(
|
|||||||
val userTurnCount = countUserTurnsBlocking()
|
val userTurnCount = countUserTurnsBlocking()
|
||||||
if (userTurnCount % reflectionInterval != 0) return
|
if (userTurnCount % reflectionInterval != 0) return
|
||||||
val convId = id
|
val convId = id
|
||||||
scope.launch {
|
backgroundScope.launch {
|
||||||
try {
|
try {
|
||||||
val turns = listOf(
|
val turns = listOf(
|
||||||
pw.binom.agentik.memory.ConversationTurn(
|
pw.binom.agentik.memory.ConversationTurn(
|
||||||
@@ -734,7 +939,7 @@ class ChatConversation(
|
|||||||
val userTurnCount = countUserTurnsBlocking()
|
val userTurnCount = countUserTurnsBlocking()
|
||||||
if (userTurnCount % skillMiningInterval != 0) return
|
if (userTurnCount % skillMiningInterval != 0) return
|
||||||
val convId = id
|
val convId = id
|
||||||
scope.launch {
|
backgroundScope.launch {
|
||||||
try {
|
try {
|
||||||
val turns = recentTurnsFromWorkingMemory(miner.maxTurns)
|
val turns = recentTurnsFromWorkingMemory(miner.maxTurns)
|
||||||
if (turns.isEmpty()) return@launch
|
if (turns.isEmpty()) return@launch
|
||||||
@@ -779,8 +984,14 @@ class ChatConversation(
|
|||||||
* пишет в audit + working memory, возвращает пару (callId, текст результата).
|
* пишет в audit + working memory, возвращает пару (callId, текст результата).
|
||||||
* Сам `addToolResult` делает вызывающий — нам нужен callId, который иначе
|
* Сам `addToolResult` делает вызывающий — нам нужен callId, который иначе
|
||||||
* негде взять (в LiteToolCall id отсутствует).
|
* негде взять (в LiteToolCall id отсутствует).
|
||||||
|
*
|
||||||
|
* **Interrupt-safe.** Tool исполняется в отдельном sub-Job ([currentToolJob]),
|
||||||
|
* чтобы [interrupt] мог отменить его точечно через `Job.cancel()`. При отмене
|
||||||
|
* возвращается маркер `[cancelled by user]` — runTurn запишет это в working
|
||||||
|
* memory как `ToolExchange(wasCancelled = true)`, и LiteConv получает
|
||||||
|
* честный результат через последующий `addToolResult` (модель видит правду).
|
||||||
*/
|
*/
|
||||||
private suspend fun runToolAndPersist(call: LiteToolCall): Pair<String, String> {
|
private suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
|
||||||
val callId = newId("tc")
|
val callId = newId("tc")
|
||||||
val resultId = newId("tr")
|
val resultId = newId("tr")
|
||||||
val argsJson = encodeArgsJson(call.arguments)
|
val argsJson = encodeArgsJson(call.arguments)
|
||||||
@@ -801,36 +1012,57 @@ class ChatConversation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val resultText: String = if (toolsetDispatch != null) {
|
// Запускаем tool в отдельном sub-Job внутри нашего scope. Это даёт
|
||||||
// Через тулсет-диспетчер: активный тул выполняется напрямую,
|
// interrupt() возможность отменить конкретно tool (а не весь activeTurn).
|
||||||
// тул из неактивного тулсета — auto-activate + выполнение,
|
// scope — наш собственный (см. поле `scope` в ChatConversation), живёт
|
||||||
// неизвестный — fallback в base dispatcher (плоские тулы).
|
// до close() — независимо от activeTurn.
|
||||||
try {
|
//
|
||||||
val outcome = toolsetDispatch.dispatch(call.name, argsJson)
|
// Сам tool исполняется ВНУТРИ toolsetDispatch.dispatch() (suspend),
|
||||||
when (outcome) {
|
// которая оборачивает invoke в runInterruptible(coroutineContext).
|
||||||
is ToolsetDispatchPolicy.Outcome.Ran -> outcome.result.ifBlank { "<empty result>" }
|
// Поэтому при Job.cancel() через currentToolJob — реальный блокирующий
|
||||||
is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]"
|
// тред получит Thread.interrupt() → кооперативные blocking tools
|
||||||
|
// (Thread.sleep, blocking I/O с timeout) будут прерваны.
|
||||||
|
val toolDeferred = scope.async {
|
||||||
|
if (toolsetDispatch == null) {
|
||||||
|
val t = toolsByName[call.name]
|
||||||
|
if (t == null) {
|
||||||
|
log.warn { "tool '${call.name}' requested but not registered" }
|
||||||
|
"[tool not found: ${call.name}]"
|
||||||
|
} else {
|
||||||
|
t.tool.invoke(argsJson)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
|
|
||||||
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val tool = toolsByName[call.name]
|
|
||||||
if (tool == null) {
|
|
||||||
log.warn { "tool '${call.name}' requested but not registered" }
|
|
||||||
"[tool not found: ${call.name}]"
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
val d = toolsetDispatch
|
||||||
tool.tool.invoke(argsJson).ifBlank { "<empty result>" }
|
when (val o = d.dispatch(call.name, argsJson)) {
|
||||||
} catch (e: Throwable) {
|
is ToolsetDispatchPolicy.Outcome.Ran -> o.result
|
||||||
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
|
is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]"
|
||||||
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
currentToolJob = toolDeferred
|
||||||
|
|
||||||
emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText))
|
val resultText: String = try {
|
||||||
|
toolDeferred.await()
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Текущий tool был прерван через interrupt(). Это нормальный flow —
|
||||||
|
// runTurn увидит cancelled tool в working memory и LiteConv получит
|
||||||
|
// честный результат через addToolResult.
|
||||||
|
"[cancelled by user]"
|
||||||
|
} catch (e: java.lang.InterruptedException) {
|
||||||
|
// Tool выбросил InterruptedException естественно (тест-фикстура
|
||||||
|
// ставит cancelFlag и заводит Thread.sleep в loop, видит и кидает).
|
||||||
|
// runInterruptible не конвертировал — parent Job не был cancelled.
|
||||||
|
// Но для пользователя это то же самое: tool отменён юзером.
|
||||||
|
"[cancelled by user]"
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
|
||||||
|
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
|
||||||
|
} finally {
|
||||||
|
currentToolJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultAt = now()
|
||||||
|
emitEvent(ProtoEvent.ToolResult(date = resultAt, id = resultId, result = resultText))
|
||||||
|
|
||||||
if (!record.isTemporal) {
|
if (!record.isTemporal) {
|
||||||
messageStore.append(
|
messageStore.append(
|
||||||
@@ -839,12 +1071,18 @@ class ChatConversation(
|
|||||||
conversationId = id,
|
conversationId = id,
|
||||||
toolCallId = callId,
|
toolCallId = callId,
|
||||||
result = resultText,
|
result = resultText,
|
||||||
createdAt = now(),
|
createdAt = resultAt,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return callId to resultText
|
return WorkingMemoryEntry.ToolExchange(
|
||||||
|
sourceMessageId = callId,
|
||||||
|
toolName = call.name,
|
||||||
|
toolArgsJson = argsJson,
|
||||||
|
resultText = resultText,
|
||||||
|
wasCancelled = resultText == "[cancelled by user]",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -854,17 +1092,25 @@ class ChatConversation(
|
|||||||
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
|
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
|
||||||
liteConv?.let { return it }
|
liteConv?.let { return it }
|
||||||
|
|
||||||
val wm = if (record.isTemporal) emptyList() else workingMemory.list(id)
|
// System prompt передаётся в LiteConversationConfig.systemInstruction, не в
|
||||||
val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm
|
// initialMessages. Хранится ТОЛЬКО in-memory в ChatConversation.systemPrompt,
|
||||||
.firstOrNull { it.entry is WorkingMemoryEntry.System }
|
// при каждом создании LiteConversation берётся свежий (отражает текущий SOUL,
|
||||||
?.let { (it.entry as WorkingMemoryEntry.System).text }
|
// активные toolsets, актуальные skills/reflections на момент старта ChatAgent).
|
||||||
?: systemPrompt
|
// В working_memory System-entries не пишем — иначе старые диалоги видели бы
|
||||||
|
// замороженный на момент создания промпт, и SOUL/toolsets не обновлялись бы
|
||||||
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else wm
|
// без рестарта агента.
|
||||||
|
//
|
||||||
|
// User/Assistant — обычные LiteMessage(user/model, text). ToolExchange —
|
||||||
|
// синтетический блок "один tool-call + результат", в LiteConv превращается в
|
||||||
|
// LiteMessage(TOOL, ToolResult). LiteRT-LM матчит по `name` — callId из
|
||||||
|
// sourceMessageId пробрасывается для трассировки.
|
||||||
|
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else workingMemory.list(id)
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
|
val isRelevant = row.entry is WorkingMemoryEntry.User
|
||||||
|
|| row.entry is WorkingMemoryEntry.Assistant
|
||||||
|
|| row.entry is WorkingMemoryEntry.ToolExchange
|
||||||
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
|
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
|
||||||
isUserOrAssistant && !isPendingUser
|
isRelevant && !isPendingUser
|
||||||
}
|
}
|
||||||
.mapNotNull { row ->
|
.mapNotNull { row ->
|
||||||
val e: WorkingMemoryEntry = row.entry
|
val e: WorkingMemoryEntry = row.entry
|
||||||
@@ -874,13 +1120,23 @@ class ChatConversation(
|
|||||||
applyContextPrefix(e.content.toLiteContents(), e.context),
|
applyContextPrefix(e.content.toLiteContents(), e.context),
|
||||||
)
|
)
|
||||||
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
|
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
|
||||||
|
is WorkingMemoryEntry.ToolExchange -> LiteMessage(
|
||||||
|
LiteRole.TOOL,
|
||||||
|
listOf(
|
||||||
|
LiteContentPart.ToolResult(
|
||||||
|
callId = e.sourceMessageId,
|
||||||
|
name = e.toolName,
|
||||||
|
response = e.resultText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = LiteConversationConfig(
|
val config = LiteConversationConfig(
|
||||||
systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() },
|
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
|
||||||
initialMessages = pastTurns,
|
initialMessages = pastTurns,
|
||||||
tools = tools.map { it.tool },
|
tools = tools.map { it.tool },
|
||||||
)
|
)
|
||||||
@@ -898,6 +1154,9 @@ class ChatConversation(
|
|||||||
*
|
*
|
||||||
* Благодаря audit-записи ошибка видна не только в live-стриме, но и при
|
* Благодаря audit-записи ошибка видна не только в live-стриме, но и при
|
||||||
* backfill через `getMessages` (переподключение / polling).
|
* backfill через `getMessages` (переподключение / polling).
|
||||||
|
*
|
||||||
|
* `End` event здесь НЕ эмитим — его эмитит finally-блок в `runTurn`,
|
||||||
|
* чтобы не было дублей при interrupt/error/success.
|
||||||
*/
|
*/
|
||||||
private suspend fun failTurn(message: String, code: String? = null) {
|
private suspend fun failTurn(message: String, code: String? = null) {
|
||||||
val ts = now()
|
val ts = now()
|
||||||
@@ -913,7 +1172,6 @@ class ChatConversation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
emitEvent(ProtoEvent.Error(date = ts, message = message, code = code))
|
emitEvent(ProtoEvent.Error(date = ts, message = message, code = code))
|
||||||
emitEvent(ProtoEvent.End(date = ts))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun now(): Instant =
|
private fun now(): Instant =
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.request.head
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentLength
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-скачиватель больших моделей с поддержкой докачки через `Range: bytes=N-`.
|
||||||
|
*
|
||||||
|
* Используется двумя сценариями:
|
||||||
|
* 1. `agentik pull-model` (subcommand в Main.kt) — явный прогон с прогрессом в stdout.
|
||||||
|
* 2. На старте server'а, если `AGENTIK_AUTO_DOWNLOAD_MODEL=1` — качает только когда
|
||||||
|
* файла по `AGENTIK_GOOGLE_MODEL_PATH` нет и `AGENTIK_LLM_BACKEND=google`.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. HEAD `url` → `Content-Length` (полный размер), `Accept-Ranges: bytes` (опц.).
|
||||||
|
* 2. Если `<dest>.part` уже есть и его размер < `Content-Length` — открываем
|
||||||
|
* на append и шлём `Range: bytes=<part.size>-`. Если сервер возвращает
|
||||||
|
* 200 OK вместо 206 — стартуем с нуля (`.part` удаляется, Range не
|
||||||
|
* поддерживается).
|
||||||
|
* 3. Стрим байтов пишем в `.part` через `RandomAccessFile`; каждые ~8 MB
|
||||||
|
* зовём progress callback.
|
||||||
|
* 4. На завершении сравниваем `.part.size()` с `Content-Length`; при наличии
|
||||||
|
* [sha256Url] — проверяем SHA-256.
|
||||||
|
* 5. Атомарный rename `.part` → финальный путь.
|
||||||
|
*
|
||||||
|
* Размер файла на 2026-09 — gemma-4-E2B-it.litertlm ~2.5 GB. Скорость
|
||||||
|
* ограничена сетью клиента; resume при обрыве обязателен.
|
||||||
|
*/
|
||||||
|
class ModelDownloader(
|
||||||
|
private val httpClient: HttpClient = defaultHttpClient(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Скачивает [url] в [destPath]. Если файл уже есть и совпадает с ожидаемым
|
||||||
|
* размером — возвращает [DownloadResult] с `bytes=0` и не качает заново.
|
||||||
|
*
|
||||||
|
* @param destPath абсолютный или относительный путь к финальному файлу.
|
||||||
|
* @param sha256Url опциональный URL `.sha256` файла для верификации
|
||||||
|
* (формат `<hex> <basename>` по стандарту `sha256sum` или просто hex).
|
||||||
|
* Если `null` — верификация пропускается.
|
||||||
|
* @param progress вызывается периодически с `(downloaded, total)`.
|
||||||
|
* `total = -1` если неизвестен.
|
||||||
|
*/
|
||||||
|
suspend fun download(
|
||||||
|
url: String,
|
||||||
|
destPath: String,
|
||||||
|
sha256Url: String? = null,
|
||||||
|
progress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
|
||||||
|
): DownloadResult {
|
||||||
|
val destFile = File(destPath).absoluteFile
|
||||||
|
destFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
log.info { "model download: HEAD $url" }
|
||||||
|
val (totalSize, supportsRanges) = probe(url)
|
||||||
|
|
||||||
|
// Файл уже на месте и совпадает по размеру — no-op.
|
||||||
|
if (destFile.exists() && totalSize > 0 && destFile.length() == totalSize) {
|
||||||
|
log.info { "model download: already present at $destPath (${destFile.length()} bytes), skipping" }
|
||||||
|
return DownloadResult(bytes = 0, total = totalSize, resumedFrom = 0, duration = Duration.ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
val partFile = File("$destPath.part")
|
||||||
|
val resumedFrom = if (partFile.exists() && partFile.length() > 0 && supportsRanges) {
|
||||||
|
log.info { "model download: resuming from ${partFile.length()} bytes" }
|
||||||
|
partFile.length()
|
||||||
|
} else {
|
||||||
|
if (partFile.exists() && partFile.length() > 0) {
|
||||||
|
log.info { "model download: discarding stale .part (server doesn't support Range)" }
|
||||||
|
partFile.delete()
|
||||||
|
}
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
val duration = measureTime {
|
||||||
|
streamToFile(
|
||||||
|
url = url,
|
||||||
|
partFile = partFile,
|
||||||
|
resumedFrom = resumedFrom,
|
||||||
|
totalSize = totalSize,
|
||||||
|
progress = progress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > 0 && partFile.length() != totalSize) {
|
||||||
|
error("model download: size mismatch — expected $totalSize bytes, got ${partFile.length()} bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sha256Url != null) {
|
||||||
|
verifySha256(partFile, sha256Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.move(
|
||||||
|
partFile.toPath(),
|
||||||
|
destFile.toPath(),
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING,
|
||||||
|
)
|
||||||
|
log.info { "model download: done — $destPath (${destFile.length()} bytes in ${duration})" }
|
||||||
|
return DownloadResult(
|
||||||
|
bytes = destFile.length(),
|
||||||
|
total = totalSize,
|
||||||
|
resumedFrom = resumedFrom,
|
||||||
|
duration = duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Probe(val totalSize: Long, val supportsRanges: Boolean)
|
||||||
|
|
||||||
|
private suspend fun probe(url: String): Probe {
|
||||||
|
val resp = httpClient.head(url)
|
||||||
|
val totalSize = resp.contentLength() ?: -1L
|
||||||
|
val acceptRanges = resp.headers[HttpHeaders.AcceptRanges]?.equals("bytes", ignoreCase = true) == true
|
||||||
|
if (totalSize <= 0) {
|
||||||
|
log.warn { "model download: server did not return Content-Length, progress will be indeterminate" }
|
||||||
|
}
|
||||||
|
return Probe(totalSize = totalSize.coerceAtLeast(-1L), supportsRanges = acceptRanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun streamToFile(
|
||||||
|
url: String,
|
||||||
|
partFile: File,
|
||||||
|
resumedFrom: Long,
|
||||||
|
totalSize: Long,
|
||||||
|
progress: (Long, Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val statement = httpClient.prepareGet(url) {
|
||||||
|
if (resumedFrom > 0) {
|
||||||
|
headers.append(HttpHeaders.Range, "bytes=$resumedFrom-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareGet().execute { } ловит streaming response — пока лямбда не
|
||||||
|
// вернулась, bodyAsChannel() читается по сети. Возвращать нужно после
|
||||||
|
// полной вычитки, иначе Ktor закроет канал и оставшиеся байты пропадут.
|
||||||
|
statement.execute { response ->
|
||||||
|
when {
|
||||||
|
response.status == HttpStatusCode.PartialContent && resumedFrom > 0 -> {
|
||||||
|
// 206: докачка
|
||||||
|
}
|
||||||
|
response.status.isSuccess() -> {
|
||||||
|
if (resumedFrom > 0) {
|
||||||
|
log.warn { "model download: server ignored Range, restarting from 0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> error("model download: HTTP ${response.status.value} ${response.status.description}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val channel: ByteReadChannel = response.bodyAsChannel()
|
||||||
|
writeStream(channel, partFile, response.status, resumedFrom, totalSize, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun writeStream(
|
||||||
|
channel: ByteReadChannel,
|
||||||
|
partFile: File,
|
||||||
|
status: HttpStatusCode,
|
||||||
|
resumedFrom: Long,
|
||||||
|
totalSize: Long,
|
||||||
|
progress: (Long, Long) -> Unit,
|
||||||
|
) {
|
||||||
|
// Используем InputStream-обёртку поверх ByteReadChannel — она умеет
|
||||||
|
// блокирующий read(buf) и не требует ручного pump'а через NIO ByteBuffer.
|
||||||
|
// Преобразование в RandomAccessFile идёт в Dispatchers.IO.
|
||||||
|
val input: InputStream = channel.toInputStream()
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
RandomAccessFile(partFile, "rw").use { raf ->
|
||||||
|
if (status == HttpStatusCode.PartialContent) {
|
||||||
|
raf.seek(resumedFrom)
|
||||||
|
} else {
|
||||||
|
raf.setLength(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
var downloaded = if (status == HttpStatusCode.PartialContent) resumedFrom else 0L
|
||||||
|
var lastReported = downloaded
|
||||||
|
val reportEvery = 8L * 1024 * 1024 // 8 MB
|
||||||
|
|
||||||
|
log.info { "model download: stream begin (resumedFrom=$resumedFrom, totalSize=$totalSize)" }
|
||||||
|
input.use { stream ->
|
||||||
|
while (true) {
|
||||||
|
val read = stream.read(buf)
|
||||||
|
if (read < 0) break
|
||||||
|
if (read == 0) {
|
||||||
|
// Согласно контракту InputStream.read(buf) может вернуть 0
|
||||||
|
// если buf.length == 0 — не наш случай; но и как защита от
|
||||||
|
// зацикливания на нулевом чтении даём планировщику тик.
|
||||||
|
kotlinx.coroutines.yield()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raf.write(buf, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
if (downloaded - lastReported >= reportEvery || (totalSize in 1..downloaded)) {
|
||||||
|
progress(downloaded, totalSize)
|
||||||
|
lastReported = downloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress(downloaded, totalSize)
|
||||||
|
log.info { "model download: stream end ($downloaded bytes written)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun verifySha256(file: File, sha256Url: String) {
|
||||||
|
val expectedHex = httpClient.prepareGet(sha256Url).execute { response ->
|
||||||
|
response.bodyAsText().trim().substringBefore(' ').lowercase()
|
||||||
|
}
|
||||||
|
val actual = withContext(Dispatchers.IO) {
|
||||||
|
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n <= 0) break
|
||||||
|
digest.update(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
if (actual != expectedHex) {
|
||||||
|
file.delete()
|
||||||
|
error("model download: SHA-256 mismatch — expected $expectedHex, got $actual")
|
||||||
|
}
|
||||||
|
log.info { "model download: SHA-256 verified ($actual)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DownloadResult(
|
||||||
|
/** Сколько байт записано в текущем прогоне (0 = skip/no-op). */
|
||||||
|
val bytes: Long,
|
||||||
|
/** Полный размер файла по Content-Length (`-1` если неизвестен). */
|
||||||
|
val total: Long,
|
||||||
|
/** Сколько байт уже было в `.part` до старта текущего прогона (0 = с нуля). */
|
||||||
|
val resumedFrom: Long,
|
||||||
|
/** Время, потраченное на саму запись (без HEAD/verify). */
|
||||||
|
val duration: Duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_GEMMA_URL: String =
|
||||||
|
"https://static.binom.pw/models/gemma-4-E2B-it.litertlm"
|
||||||
|
|
||||||
|
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(HttpTimeout) {
|
||||||
|
// connectTimeout — дефолт Ktor (≈ секунды), requestTimeout снимаем:
|
||||||
|
// скачивание 2.5 GB по медленной сети может занять минуты.
|
||||||
|
requestTimeoutMillis = Long.MAX_VALUE
|
||||||
|
}
|
||||||
|
followRedirects = true
|
||||||
|
expectSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import pw.binom.agentik.skills.SkillCatalog
|
|||||||
import pw.binom.agentik.skills.SkillFile
|
import pw.binom.agentik.skills.SkillFile
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.storage.MessageRecord
|
||||||
|
import pw.binom.agentik.storage.WorkingMemoryEntry
|
||||||
import pw.binom.agentik.storage.sqlite.SqliteStores
|
import pw.binom.agentik.storage.sqlite.SqliteStores
|
||||||
import pw.binom.litert.LiteContentPart
|
import pw.binom.litert.LiteContentPart
|
||||||
import pw.binom.litert.LiteConversation
|
import pw.binom.litert.LiteConversation
|
||||||
@@ -71,32 +73,41 @@ class ChatAgentTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createConversation seeds system prompt into working memory`() = runTest {
|
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
|
||||||
|
// System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt
|
||||||
|
// и едет в LLM через LiteConversationConfig.systemInstruction. В
|
||||||
|
// working_memory ничего не пишется — старт system prompt чистый,
|
||||||
|
// 0 entries.
|
||||||
val agent = newAgent()
|
val agent = newAgent()
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
val wm = storage.workingMemoryStore.list(conv.id)
|
val wm = storage.workingMemoryStore.list(conv.id)
|
||||||
assertEquals(1, wm.size)
|
assertEquals(0, wm.size)
|
||||||
val first = wm[0]
|
// System prompt виден через LiteConversationConfig, который LLM получит
|
||||||
val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System
|
// при первом send (см. `send passes system prompt and past history to LLM on first send`).
|
||||||
assertEquals("be brief", sysEntry.text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `skills are appended to the system prompt in working memory`() = runTest {
|
fun `skills are appended to the system prompt passed to LLM`() = runTest {
|
||||||
|
// Skills добавляются в system prompt на лету при сборке ChatAgent.
|
||||||
|
// Проверяем это через то, что увидит LLM — systemInstruction в
|
||||||
|
// LiteConversationConfig (а не через working_memory, куда теперь
|
||||||
|
// ничего про system prompt не пишется).
|
||||||
val skills = SkillCatalog(
|
val skills = SkillCatalog(
|
||||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||||
)
|
)
|
||||||
val agent = newAgent(skills = skills)
|
val agent = newAgent(skills = skills)
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
val system = storage.workingMemoryStore.list(conv.id).first().entry
|
val system = fakeLlm.lastConfig!!.systemInstruction
|
||||||
as pw.binom.agentik.storage.WorkingMemoryEntry.System
|
assertNotNull(system)
|
||||||
assertTrue("be brief" in system.text)
|
assertTrue("be brief" in system!!, "base prompt missing: $system")
|
||||||
assertTrue("## Навыки" in system.text)
|
assertTrue("## Навыки" in system, "skills section missing: $system")
|
||||||
assertTrue("lint" in system.text)
|
assertTrue("lint" in system, "skill name missing: $system")
|
||||||
assertTrue("lint things" in system.text)
|
assertTrue("lint things" in system, "skill description missing: $system")
|
||||||
assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body")
|
assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -199,12 +210,13 @@ class ChatAgentTest {
|
|||||||
fakeLlm.reply = "second reply"
|
fakeLlm.reply = "second reply"
|
||||||
conv2.send(listOf(Content.Text("second user")))
|
conv2.send(listOf(Content.Text("second user")))
|
||||||
|
|
||||||
// Первая беседа должна иметь только свою систему + 1 user + 1 assistant
|
// В working_memory теперь НЕТ System-entries — только user + assistant.
|
||||||
|
// Системный промт живёт в ChatConversation.systemPrompt и едет в LLM
|
||||||
|
// через LiteConversationConfig.systemInstruction.
|
||||||
val wm1 = storage.workingMemoryStore.list(conv1.id)
|
val wm1 = storage.workingMemoryStore.list(conv1.id)
|
||||||
assertEquals(3, wm1.size)
|
assertEquals(2, wm1.size)
|
||||||
// Вторая беседа — только своё
|
|
||||||
val wm2 = storage.workingMemoryStore.list(conv2.id)
|
val wm2 = storage.workingMemoryStore.list(conv2.id)
|
||||||
assertEquals(3, wm2.size)
|
assertEquals(2, wm2.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -229,34 +241,43 @@ class ChatAgentTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `multi-turn conversation accumulates in single LiteConversation`() = runTest {
|
fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest {
|
||||||
|
// Новая семантика (radical close+recreate после commit 7):
|
||||||
|
// каждый turn закрывает LiteConv и на следующем send() создаёт новую
|
||||||
|
// через getOrCreateLiteConversation, которая пересобирает initialMessages
|
||||||
|
// из working memory. То есть LiteConv — один на turn, не на диалог.
|
||||||
|
// Преимущество: interrupt можно сделать тривиально (close + cancelProcess),
|
||||||
|
// KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B).
|
||||||
val agent = newAgent()
|
val agent = newAgent()
|
||||||
fakeLlm.rememberHistory = true
|
fakeLlm.rememberHistory = true
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
fakeLlm.reply = "first reply"
|
fakeLlm.reply = "first reply"
|
||||||
conv.send(listOf(Content.Text("first user")))
|
conv.send(listOf(Content.Text("first user")))
|
||||||
// первый turn: WM = [system, user, assistant]
|
// первый turn: WM = [user, assistant]
|
||||||
assertEquals(3, storage.workingMemoryStore.list(conv.id).size)
|
assertEquals(2, storage.workingMemoryStore.list(conv.id).size)
|
||||||
|
|
||||||
fakeLlm.reply = "second reply"
|
fakeLlm.reply = "second reply"
|
||||||
conv.send(listOf(Content.Text("second user")))
|
conv.send(listOf(Content.Text("second user")))
|
||||||
// второй turn: WM должен вырасти до [system, user, assistant, user, assistant]
|
// второй turn: WM должен вырасти до [user, assistant, user, assistant]
|
||||||
val wm = storage.workingMemoryStore.list(conv.id)
|
val wm = storage.workingMemoryStore.list(conv.id)
|
||||||
assertEquals(5, wm.size)
|
System.err.println("[TEST] wm.size=${wm.size}")
|
||||||
// Длинно-живущий LiteConversation: один на ChatConversation, история
|
wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") }
|
||||||
// накапливается через sendStreamContents, без пересоздания.
|
assertEquals(4, wm.size)
|
||||||
assertEquals(1, fakeLlm.conversations.size)
|
// Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов.
|
||||||
val history = fakeLlm.conversations[0].history
|
assertEquals(2, fakeLlm.conversations.size)
|
||||||
assertEquals(4, history.size)
|
// Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2
|
||||||
assertEquals("first user", history[0].text)
|
// (он передаётся в sendStreamContents, чтобы не дублироваться).
|
||||||
assertEquals(LiteRole.USER, history[0].role)
|
val reopened = fakeLlm.conversations.last()
|
||||||
assertEquals("first reply", history[1].text)
|
assertEquals(2, reopened.initialMessages.size)
|
||||||
assertEquals(LiteRole.MODEL, history[1].role)
|
assertEquals("first user", reopened.initialMessages[0].text)
|
||||||
assertEquals("second user", history[2].text)
|
assertEquals(LiteRole.USER, reopened.initialMessages[0].role)
|
||||||
assertEquals(LiteRole.USER, history[2].role)
|
assertEquals("first reply", reopened.initialMessages[1].text)
|
||||||
assertEquals("second reply", history[3].text)
|
assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role)
|
||||||
assertEquals(LiteRole.MODEL, history[3].role)
|
// После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4
|
||||||
|
assertEquals(4, reopened.history.size)
|
||||||
|
assertEquals("second reply", reopened.history.last().text)
|
||||||
|
assertEquals(LiteRole.MODEL, reopened.history.last().role)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -318,11 +339,22 @@ class ChatAgentTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `interrupt cancels active send`() = runTest {
|
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
|
||||||
|
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
|
||||||
|
// бросает CancellationException в стриме, runTurn выходит через finally.
|
||||||
|
// Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty())
|
||||||
|
// — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted.
|
||||||
val agent = newAgent()
|
val agent = newAgent()
|
||||||
fakeLlm.slow = true
|
fakeLlm.slow = true
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
// Подписываемся на events ДО send() — SharedFlow без replay, после
|
||||||
|
// отправки событий подписка ничего не увидит.
|
||||||
|
val events = mutableListOf<ProtoEvent>()
|
||||||
|
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
val sendJob = launch {
|
val sendJob = launch {
|
||||||
try {
|
try {
|
||||||
conv.send(listOf(Content.Text("hi")))
|
conv.send(listOf(Content.Text("hi")))
|
||||||
@@ -334,11 +366,84 @@ class ChatAgentTest {
|
|||||||
delay(200)
|
delay(200)
|
||||||
conv.interrupt()
|
conv.interrupt()
|
||||||
sendJob.join()
|
sendJob.join()
|
||||||
|
eventsJob.cancel()
|
||||||
|
|
||||||
// user сообщение в audit должно быть, assistant — нет (был отменён)
|
// audit: только user (assistant не успел сгенериться)
|
||||||
val msgs = storage.messageStore.listAll(conv.id)
|
val msgs = storage.messageStore.listAll(conv.id)
|
||||||
assertEquals(1, msgs.size)
|
assertEquals(1, msgs.size)
|
||||||
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
|
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
|
||||||
|
|
||||||
|
// working memory: только user (assistant skipped because пустой)
|
||||||
|
val wm = storage.workingMemoryStore.list(conv.id)
|
||||||
|
assertEquals(1, wm.size)
|
||||||
|
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
|
||||||
|
|
||||||
|
// events: должны включать Interrupted + End
|
||||||
|
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt after tool execution preserves tool result in working memory`() = runTest {
|
||||||
|
// Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()":
|
||||||
|
// 1. LLM скриптован на ToolCalls([echo_tool])
|
||||||
|
// 2. Tool реально вызывается через toolsetDispatch.dispatch()
|
||||||
|
// 3. interrupt() приходит в окне между финальным text и завершением turn'а
|
||||||
|
//
|
||||||
|
// В audit log: user + ToolCall + ToolResult (инструмент выполнился).
|
||||||
|
// В working_memory: user + ToolExchange(result=echo output, wasCancelled=false).
|
||||||
|
// В events: ToolCall + ToolResult + Interrupted + End.
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
// LLM скриптован: tool call.
|
||||||
|
fakeLlm.scriptedReplies = mutableListOf(
|
||||||
|
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
|
||||||
|
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
|
||||||
|
}
|
||||||
|
agent.registerToolForTest("echo_tool", echoTool)
|
||||||
|
|
||||||
|
// Подписываемся ДО send — SharedFlow без replay
|
||||||
|
val events = mutableListOf<ProtoEvent>()
|
||||||
|
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sendJob = launch {
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text("run echo tool")))
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {}
|
||||||
|
}
|
||||||
|
// Ждём пока инструмент выполнится (turn завершится нормально)
|
||||||
|
sendJob.join()
|
||||||
|
// interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД,
|
||||||
|
// но проверяем что events включает все ожидаемые типы.
|
||||||
|
conv.interrupt()
|
||||||
|
eventsJob.cancel()
|
||||||
|
|
||||||
|
// audit: user + toolcall + toolresult (tool выполнился), assistant может быть
|
||||||
|
val msgs = storage.messageStore.listAll(conv.id)
|
||||||
|
val toolResult = msgs.filterIsInstance<pw.binom.agentik.storage.MessageRecord.ToolResult>().firstOrNull()
|
||||||
|
assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально")
|
||||||
|
val toolResultResult = toolResult!!.result!!
|
||||||
|
assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult")
|
||||||
|
|
||||||
|
// working memory: user + tool_exchange
|
||||||
|
val wm = storage.workingMemoryStore.list(conv.id)
|
||||||
|
val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) }
|
||||||
|
assertEquals(1, exchanges.size)
|
||||||
|
assertEquals("echo_tool", exchanges[0].toolName)
|
||||||
|
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
||||||
|
assertTrue(exchanges[0].resultText.contains("echo"))
|
||||||
|
|
||||||
|
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился).
|
||||||
|
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -435,7 +540,11 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
||||||
|
|
||||||
assertEquals(1, toolLlm.toolCallCount, "expected one round-trip through LiteConversation")
|
// sendStreamContents вызывается дважды: первый раз с user-сообщением
|
||||||
|
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
||||||
|
// триггера continuation у stateless-бэкендов (OpenAI). На этой fake
|
||||||
|
// LiteLlm оба попадают в счётчик.
|
||||||
|
assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation")
|
||||||
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
|
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
|
||||||
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
|
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
|
||||||
assertEquals("final reply", toolLlm.finalReplyEmitted,
|
assertEquals("final reply", toolLlm.finalReplyEmitted,
|
||||||
|
|||||||
@@ -9,18 +9,32 @@ import pw.binom.litert.LiteDelta
|
|||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.litert.LiteMessage
|
import pw.binom.litert.LiteMessage
|
||||||
import pw.binom.litert.LiteRole
|
import pw.binom.litert.LiteRole
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
||||||
* заданной строкой [reply] двумя фрагментами + done.
|
* заданной строкой [reply] двумя фрагментами + done.
|
||||||
*/
|
*/
|
||||||
internal class FakeLiteLlm : LiteLlm {
|
internal class FakeLiteLlm : LiteLlm {
|
||||||
|
sealed class Reply {
|
||||||
|
data class Text(val text: String) : Reply()
|
||||||
|
data class ToolCalls(val calls: List<Pair<String, Map<String, Any?>>>) : Reply()
|
||||||
|
}
|
||||||
|
|
||||||
override val backendName: String = "fake"
|
override val backendName: String = "fake"
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
var reply: String = ""
|
var reply: String = ""
|
||||||
var rememberHistory: Boolean = false
|
var rememberHistory: Boolean = false
|
||||||
var slow: Boolean = false
|
var slow: Boolean = false
|
||||||
var failMessage: String? = null
|
var failMessage: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
||||||
|
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
||||||
|
* кончился — fallback на [reply] (text).
|
||||||
|
*/
|
||||||
|
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
||||||
|
|
||||||
var lastConfig: LiteConversationConfig? = null
|
var lastConfig: LiteConversationConfig? = null
|
||||||
var lastContents: List<LiteContentPart>? = null
|
var lastContents: List<LiteContentPart>? = null
|
||||||
val conversations = mutableListOf<FakeLiteConversation>()
|
val conversations = mutableListOf<FakeLiteConversation>()
|
||||||
@@ -41,6 +55,9 @@ internal class FakeLiteLlm : LiteLlm {
|
|||||||
throw UnsupportedOperationException("not used in test")
|
throw UnsupportedOperationException("not used in test")
|
||||||
|
|
||||||
override fun close() {}
|
override fun close() {}
|
||||||
|
|
||||||
|
fun nextReply(): Reply =
|
||||||
|
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FakeLiteConversation(
|
internal class FakeLiteConversation(
|
||||||
@@ -60,21 +77,31 @@ internal class FakeLiteConversation(
|
|||||||
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
||||||
}
|
}
|
||||||
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||||
if (parent.slow) {
|
val next = parent.nextReply()
|
||||||
return kotlinx.coroutines.flow.flow {
|
return when (next) {
|
||||||
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
|
is FakeLiteLlm.Reply.Text -> {
|
||||||
kotlinx.coroutines.delay(10_000)
|
if (parent.slow) {
|
||||||
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
|
kotlinx.coroutines.flow.flow {
|
||||||
mutableHistory.add(LiteMessage.model(parent.reply))
|
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
||||||
|
kotlinx.coroutines.delay(10_000)
|
||||||
|
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
||||||
|
mutableHistory.add(LiteMessage.model(next.text))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val first = next.text.substring(0, next.text.length / 2)
|
||||||
|
val second = next.text.substring(next.text.length / 2)
|
||||||
|
flowOf(
|
||||||
|
LiteDelta(text = first),
|
||||||
|
LiteDelta(text = second, isDone = true),
|
||||||
|
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FakeLiteLlm.Reply.ToolCalls -> {
|
||||||
|
val calls = next.calls.map { (name, args) ->
|
||||||
|
LiteToolCall(name = name, arguments = args)
|
||||||
|
}
|
||||||
|
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val first = parent.reply.substring(0, parent.reply.length / 2)
|
|
||||||
val second = parent.reply.substring(parent.reply.length / 2)
|
|
||||||
return flowOf(
|
|
||||||
LiteDelta(text = first),
|
|
||||||
LiteDelta(text = second, isDone = true),
|
|
||||||
).also {
|
|
||||||
mutableHistory.add(LiteMessage.model(parent.reply))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun send(prompt: String): String {
|
override fun send(prompt: String): String {
|
||||||
|
|||||||
+17
-14
@@ -29,7 +29,6 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
|||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
|
||||||
import pw.binom.agentik.storage.sqlite.SqliteStores
|
import pw.binom.agentik.storage.sqlite.SqliteStores
|
||||||
import kotlin.test.AfterTest
|
import kotlin.test.AfterTest
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
@@ -88,11 +87,13 @@ class MemoryWiringTest {
|
|||||||
val system = openMdMemorySystem(root)
|
val system = openMdMemorySystem(root)
|
||||||
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
|
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
val wm = storage.workingMemoryStore.list(conv.id)
|
fakeLlm.reply = "ok"
|
||||||
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
|
conv.send(listOf(Content.Text("hi")))
|
||||||
val text = (sysRow.entry as WorkingMemoryEntry.System).text
|
// System prompt не пишется в working_memory — читаем то, что увидит LLM
|
||||||
assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
"system prompt should contain MEMORY_GUIDANCE")
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
|
||||||
|
"system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}")
|
||||||
agent.close()
|
agent.close()
|
||||||
system.close()
|
system.close()
|
||||||
}
|
}
|
||||||
@@ -112,10 +113,11 @@ class MemoryWiringTest {
|
|||||||
soulBody = soulBody,
|
soulBody = soulBody,
|
||||||
)
|
)
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
val wm = storage.workingMemoryStore.list(conv.id)
|
fakeLlm.reply = "ok"
|
||||||
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
|
conv.send(listOf(Content.Text("hi")))
|
||||||
val text = (sysRow.entry as WorkingMemoryEntry.System).text
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
assertTrue(text.startsWith(soulBody),
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.startsWith(soulBody),
|
||||||
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
|
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
|
||||||
assertTrue(text.contains("be brief"),
|
assertTrue(text.contains("be brief"),
|
||||||
"base prompt should still follow the soul; got: $text")
|
"base prompt should still follow the soul; got: $text")
|
||||||
@@ -135,10 +137,11 @@ class MemoryWiringTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val conv = agent.createConversation(temp = false)
|
val conv = agent.createConversation(temp = false)
|
||||||
val wm = storage.workingMemoryStore.list(conv.id)
|
fakeLlm.reply = "ok"
|
||||||
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
|
conv.send(listOf(Content.Text("hi")))
|
||||||
val text = (sysRow.entry as WorkingMemoryEntry.System).text
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
assertTrue(text.startsWith("be brief"),
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.startsWith("be brief"),
|
||||||
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
|
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
|
||||||
agent.close()
|
agent.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.response.respondBytes
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.head
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.HttpHeaders as KH
|
||||||
|
import io.ktor.utils.io.toByteArray
|
||||||
|
import java.io.File
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class ModelDownloaderTest {
|
||||||
|
|
||||||
|
private val payload = ByteArray(8192) { (it and 0xff).toByte() }
|
||||||
|
|
||||||
|
/** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */
|
||||||
|
private fun startFakeServer(): FakeServer {
|
||||||
|
val port = ServerSocket(0).use { it.localPort }
|
||||||
|
val server = embeddedServer(CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
head("/model.litertlm") {
|
||||||
|
call.response.headers.append(KH.AcceptRanges, "bytes")
|
||||||
|
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
||||||
|
call.respondText("")
|
||||||
|
}
|
||||||
|
get("/model.litertlm") {
|
||||||
|
val range = call.request.headers[KH.Range]
|
||||||
|
if (range == null) {
|
||||||
|
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
||||||
|
call.respondBytes(payload)
|
||||||
|
} else {
|
||||||
|
// Parse "bytes=N-"
|
||||||
|
val n = range.substringAfter("bytes=").substringBefore('-').toLong()
|
||||||
|
val slice = payload.copyOfRange(n.toInt(), payload.size)
|
||||||
|
call.response.status(HttpStatusCode.PartialContent)
|
||||||
|
call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}")
|
||||||
|
call.response.headers.append(KH.ContentLength, slice.size.toString())
|
||||||
|
call.respondBytes(slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(wait = false)
|
||||||
|
return FakeServer(server, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tmpFile(): File {
|
||||||
|
val dir: Path = createTempDirectory(prefix = "agentik-test-")
|
||||||
|
return dir.resolve("model.litertlm").toFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download writes full file when no part exists`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(payload.size.toLong(), result.bytes)
|
||||||
|
assertEquals(0L, result.resumedFrom)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up")
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download is no-op when final file already complete`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
dest.writeBytes(payload)
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(0L, result.bytes)
|
||||||
|
assertEquals(payload.size.toLong(), result.total)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download resumes from existing part file with Range request`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val partFile = File("${dest.absolutePath}.part")
|
||||||
|
val prefixSize = 4096
|
||||||
|
partFile.writeBytes(payload.copyOfRange(0, prefixSize))
|
||||||
|
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(prefixSize.toLong(), result.resumedFrom)
|
||||||
|
assertEquals(payload.size.toLong(), result.bytes)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download reports progress via callback`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val reports = mutableListOf<Pair<Long, Long>>()
|
||||||
|
dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
progress = { d, t -> reports += d to t },
|
||||||
|
)
|
||||||
|
assertTrue(reports.isNotEmpty(), "progress must be reported at least once")
|
||||||
|
assertEquals(payload.size.toLong(), reports.last().first)
|
||||||
|
assertEquals(payload.size.toLong(), reports.last().second)
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download fails with clear message on HTTP 404`() = runBlocking {
|
||||||
|
// Spin up server that always 404s
|
||||||
|
val port = ServerSocket(0).use { it.localPort }
|
||||||
|
val server = embeddedServer(CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
||||||
|
get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(wait = false)
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
try {
|
||||||
|
dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath)
|
||||||
|
fail("expected failure on 404")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val msg = e.message ?: ""
|
||||||
|
assertTrue("404" in msg || "Not Found" in msg,
|
||||||
|
"error should mention HTTP 404, got: $msg")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int)
|
||||||
|
}
|
||||||
@@ -150,11 +150,6 @@ class PersistenceTest {
|
|||||||
fun `working memory — append + list preserves order`() = runTest {
|
fun `working memory — append + list preserves order`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
stores.workingMemory.append(
|
|
||||||
conversationId = "c1",
|
|
||||||
entry = WorkingMemoryEntry.System(text = "you are a bot"),
|
|
||||||
now = t0,
|
|
||||||
)
|
|
||||||
stores.workingMemory.append(
|
stores.workingMemory.append(
|
||||||
conversationId = "c1",
|
conversationId = "c1",
|
||||||
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
|
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
|
||||||
@@ -166,32 +161,29 @@ class PersistenceTest {
|
|||||||
now = t1,
|
now = t1,
|
||||||
)
|
)
|
||||||
val list = stores.workingMemory.list("c1")
|
val list = stores.workingMemory.list("c1")
|
||||||
assertEquals(3, list.size)
|
assertEquals(2, list.size)
|
||||||
assertTrue(list[0].entry is WorkingMemoryEntry.System)
|
assertTrue(list[0].entry is WorkingMemoryEntry.User)
|
||||||
assertTrue(list[1].entry is WorkingMemoryEntry.User)
|
assertTrue(list[1].entry is WorkingMemoryEntry.Assistant)
|
||||||
assertTrue(list[2].entry is WorkingMemoryEntry.Assistant)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `working memory — compact without summary just drops tail`() = runTest {
|
fun `working memory — compact without summary just drops tail`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
||||||
val rows = stores.workingMemory.list("c1")
|
val rows = stores.workingMemory.list("c1")
|
||||||
// Drop начиная со второго хода (User m1) — должно остаться System.
|
// Drop начиная со второго хода (User m1) — должно остаться только User m1.
|
||||||
val dropFrom = rows[1].orderIdx
|
val dropFrom = rows[1].orderIdx
|
||||||
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
|
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
|
||||||
val after = stores.workingMemory.list("c1")
|
val after = stores.workingMemory.list("c1")
|
||||||
assertEquals(1, after.size)
|
assertEquals(1, after.size)
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.System)
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `working memory — compact with summary inserts Summary entry`() = runTest {
|
fun `working memory — compact with summary inserts Summary entry`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
||||||
@@ -200,7 +192,7 @@ class PersistenceTest {
|
|||||||
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
|
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
|
||||||
val after = stores.workingMemory.list("c1")
|
val after = stores.workingMemory.list("c1")
|
||||||
assertEquals(2, after.size)
|
assertEquals(2, after.size)
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.System)
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
val summary = after[1].entry
|
val summary = after[1].entry
|
||||||
assertIs<WorkingMemoryEntry.Summary>(summary)
|
assertIs<WorkingMemoryEntry.Summary>(summary)
|
||||||
assertTrue(summary.text.startsWith("**Goal**"))
|
assertTrue(summary.text.startsWith("**Goal**"))
|
||||||
@@ -213,31 +205,30 @@ class PersistenceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `working memory — compact with blank summaryText behaves as drop`() = runTest {
|
fun `working memory — compact with blank summaryText behaves as drop`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
val rows = stores.workingMemory.list("c1")
|
val rows = stores.workingMemory.list("c1")
|
||||||
stores.workingMemory.compact(rows[1].orderIdx, "c1", summaryText = "")
|
stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "")
|
||||||
val after = stores.workingMemory.list("c1")
|
val after = stores.workingMemory.list("c1")
|
||||||
assertEquals(1, after.size)
|
assertEquals(1, after.size)
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.System)
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `working memory — compact is atomic on other conversations`() = runTest {
|
fun `working memory — compact is atomic on other conversations`() = runTest {
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys1"), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
stores.workingMemory.append("c2", WorkingMemoryEntry.System("sys2"), t0)
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
|
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
|
||||||
|
stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0)
|
||||||
stores.workingMemory.compact(2, "c1", summaryText = "sum")
|
stores.workingMemory.compact(2, "c1", summaryText = "sum")
|
||||||
val c1 = stores.workingMemory.list("c1")
|
val c1 = stores.workingMemory.list("c1")
|
||||||
val c2 = stores.workingMemory.list("c2")
|
val c2 = stores.workingMemory.list("c2")
|
||||||
// c1: System + Summary
|
// c1: User m1 + Summary
|
||||||
assertEquals(2, c1.size)
|
assertEquals(2, c1.size)
|
||||||
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
|
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
|
||||||
// c2 не тронут
|
// c2 не тронут
|
||||||
assertEquals(2, c2.size)
|
assertEquals(2, c2.size)
|
||||||
assertTrue(c2[1].entry is WorkingMemoryEntry.User)
|
assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `:storage-core` — контракт хранилища (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Интерфейсы persistence-уровня для `:standalone`:
|
||||||
|
|
||||||
|
- `MessageStore` — append-only история сообщений по диалогу.
|
||||||
|
- `WorkingMemoryStore` — rolling buffer текущего хода (`AssistantMessage`,
|
||||||
|
`ToolExchange`, `UserMessage`, system-prompt) для fast-recovery
|
||||||
|
при reconnect/relance.
|
||||||
|
- `ConversationStore` — метаданные диалогов (id, title, model,
|
||||||
|
timestamps).
|
||||||
|
- `ReflectionStore` — LLM-reflections (свободная форма заметок
|
||||||
|
хранителя).
|
||||||
|
|
||||||
|
Решает: позволяет запустить агента на Android-in-memory, на
|
||||||
|
desktop-SQLite, или на production-SQLite, не переписывая логику.
|
||||||
|
Контракт минимален и async-friendly.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:storage-inmemory` — для тестов и Android.
|
||||||
|
- `:storage-sqlite` — прод (Desktop / server / однодесктопный
|
||||||
|
Android-development).
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
kotlin {
|
||||||
|
sourceSets.commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:storage-core:0.1.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-storage-core`.
|
||||||
|
|
||||||
|
## Что в API
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface MessageStore {
|
||||||
|
suspend fun append(conversationId: String, message: Message): Unit
|
||||||
|
suspend fun after(conversationId: String, instant: Instant, limit: Int = 100): List<Message>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkingMemoryStore {
|
||||||
|
suspend fun save(conv: String, entry: WorkingMemoryEntry): Unit
|
||||||
|
fun load(conv: String): Flow<WorkingMemoryEntry> // cold flow
|
||||||
|
suspend fun clear(conv: String): Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface WorkingMemoryEntry {
|
||||||
|
val id: String
|
||||||
|
val date: Instant
|
||||||
|
class UserMessage(...) : WorkingMemoryEntry
|
||||||
|
class AssistantMessage(...) : WorkingMemoryEntry
|
||||||
|
class ToolExchange(val toolName: String, val toolArgsJson: String, val resultText: String, val wasCancelled: Boolean) : WorkingMemoryEntry
|
||||||
|
class SystemPrompt(...) : WorkingMemoryEntry
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Контрактные тесты (общие для всех имплементаций) — в `:storage-sqlite`
|
||||||
|
и `:storage-inmemory`.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого HTTP / SSE.
|
||||||
|
- Никакой конкретной БД. Backend'ы в `:storage-*`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Контракт зафиксирован после
|
||||||
|
interrupt-имплементации (см. [INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md)).
|
||||||
@@ -7,9 +7,10 @@ import kotlinx.serialization.Serializable
|
|||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
|
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
|
||||||
*
|
*
|
||||||
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
|
* Sealed-иерархия: `User`/`Assistant` (реплики с ссылкой на audit log
|
||||||
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
|
* через [sourceMessageId]), `ToolExchange` (синтетическая запись об одном
|
||||||
* Суммаризация (для v2) добавит подтип `Summary`.
|
* tool-вызове + его результате — для replay в LiteMessage(TOOL, ToolResult)
|
||||||
|
* при пересоздании LiteConv), `Summary` (суммаризация при compaction).
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface WorkingMemoryEntry {
|
sealed interface WorkingMemoryEntry {
|
||||||
@@ -17,13 +18,6 @@ sealed interface WorkingMemoryEntry {
|
|||||||
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
|
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
|
||||||
val sourceMessageId: String?
|
val sourceMessageId: String?
|
||||||
|
|
||||||
/** Синтетический system-prompt, добавляется при создании диалога. */
|
|
||||||
@Serializable
|
|
||||||
@SerialName("system")
|
|
||||||
data class System(val text: String) : WorkingMemoryEntry {
|
|
||||||
override val sourceMessageId: String? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Реплика пользователя. */
|
/** Реплика пользователя. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("user")
|
@SerialName("user")
|
||||||
@@ -47,6 +41,32 @@ sealed interface WorkingMemoryEntry {
|
|||||||
val content: List<Content>,
|
val content: List<Content>,
|
||||||
) : WorkingMemoryEntry
|
) : WorkingMemoryEntry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синтетический блок: один tool-вызов + его результат. Синтетический — потому
|
||||||
|
* что в audit log это две отдельные записи (`MessageRecord.ToolCall` +
|
||||||
|
* `MessageRecord.ToolResult`), а в working_memory мы храним одной строкой
|
||||||
|
* для удобства replay'а.
|
||||||
|
*
|
||||||
|
* При создании новой LiteConv каждая такая запись превращается в
|
||||||
|
* `LiteMessage(TOOL, [ToolResult(callId, name, response)])` — LiteRT-LM
|
||||||
|
* матчит по `name`, `callId` берётся из [sourceMessageId] (= id исходного
|
||||||
|
* [MessageRecord.ToolCall]). Если [wasCancelled] = true, [resultText]
|
||||||
|
* содержит маркер `[cancelled by user]` — модель видит честную причину
|
||||||
|
* отсутствия результата.
|
||||||
|
*
|
||||||
|
* [sourceMessageId] = id исходного [MessageRecord.ToolCall] (для трассировки
|
||||||
|
* в audit log).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_exchange")
|
||||||
|
data class ToolExchange(
|
||||||
|
override val sourceMessageId: String,
|
||||||
|
val toolName: String,
|
||||||
|
val toolArgsJson: String,
|
||||||
|
val resultText: String,
|
||||||
|
val wasCancelled: Boolean = false,
|
||||||
|
) : WorkingMemoryEntry
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Синтетический блок: суммаризация старых ходов, сгенерированная при
|
* Синтетический блок: суммаризация старых ходов, сгенерированная при
|
||||||
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
|
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class PayloadTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `legacy plain-array payload still decodes (backward compat)`() {
|
fun `legacy plain-array payload still decodes backward compat`() {
|
||||||
val legacy = "[" +
|
val legacy = "[" +
|
||||||
"""{"type":"text","body":"old message"}""" +
|
"""{"type":"text","body":"old message"}""" +
|
||||||
"]"
|
"]"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# `:storage-inmemory` — in-memory реализация `:storage-core` (KMP, jvm + native)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
In-memory реализация `MessageStore / WorkingMemoryStore /
|
||||||
|
ConversationStore / ReflectionStore`. Все структуры держит в
|
||||||
|
`ConcurrentHashMap` + `MutableList`, фолотится на RAM
|
||||||
|
(никаких файлов).
|
||||||
|
|
||||||
|
Решает: дешёвая тестовая среда без поднятия SQLite. Позволяет
|
||||||
|
прогонять `ChatAgentTest` за миллисекунды и держать сценарии
|
||||||
|
детерминированными.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- В тестах `:standalone` (`AbstractITTest`).
|
||||||
|
- В Android-имплементации (in-memory + Android-database микс).
|
||||||
|
- В любых юнит-тестах на агенте.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
api("pw.binom.agentik:storage-inmemory:0.1.0")
|
||||||
|
api("pw.binom.agentik:storage-core:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
val storage = InMemoryStorageSystem()
|
||||||
|
val messages: MessageStore = storage.messages
|
||||||
|
val working: WorkingMemoryStore = storage.working
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-storage-inmemory`.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :storage-inmemory:allTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают (через общие contract-tests): round-trip, paged flow,
|
||||||
|
concurrent appends, working-memory replay, очистку.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого persistence. Перезапуск процесса — данные пропали.
|
||||||
|
Это нормально для тестов и Android in-memory.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном (в режиме тестов). Контракт-совместима
|
||||||
|
с `:storage-sqlite` 1:1 — переключение `AGENTIK_STORAGE_BACKEND=memory`
|
||||||
|
в `:standalone`.
|
||||||
+1
-1
@@ -68,7 +68,7 @@ class InMemoryConversationStoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rename updates title and updatedAt, returns new updatedAt`() = runTest {
|
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryConversationStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||||
|
|||||||
+10
-15
@@ -15,21 +15,19 @@ class InMemoryWorkingMemoryStoreTest {
|
|||||||
fun `append assigns sequential order_idx starting from 0`() = runTest {
|
fun `append assigns sequential order_idx starting from 0`() = runTest {
|
||||||
val store = InMemoryWorkingMemoryStore()
|
val store = InMemoryWorkingMemoryStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
|
|
||||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||||
val rows = store.list("c1")
|
val rows = store.list("c1")
|
||||||
assertEquals(3, rows.size)
|
assertEquals(2, rows.size)
|
||||||
assertEquals(listOf(0L, 1L, 2L), rows.map { it.orderIdx })
|
assertEquals(listOf(0L, 1L), rows.map { it.orderIdx })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `order_idx continues across conversations independently`() = runTest {
|
fun `order_idx continues across conversations independently`() = runTest {
|
||||||
val store = InMemoryWorkingMemoryStore()
|
val store = InMemoryWorkingMemoryStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
|
||||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
|
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
|
||||||
store.append("c2", WorkingMemoryEntry.System("b"), t0)
|
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
|
||||||
// c2 должен начать с 0, не продолжать c1
|
// c2 должен начать с 0, не продолжать c1
|
||||||
val rows2 = store.list("c2")
|
val rows2 = store.list("c2")
|
||||||
assertEquals(1, rows2.size)
|
assertEquals(1, rows2.size)
|
||||||
@@ -56,8 +54,8 @@ class InMemoryWorkingMemoryStoreTest {
|
|||||||
fun `clear removes all rows for a conversation`() = runTest {
|
fun `clear removes all rows for a conversation`() = runTest {
|
||||||
val store = InMemoryWorkingMemoryStore()
|
val store = InMemoryWorkingMemoryStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
|
||||||
store.append("c2", WorkingMemoryEntry.System("b"), t0)
|
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
|
||||||
store.clear("c1")
|
store.clear("c1")
|
||||||
assertEquals(emptyList(), store.list("c1"))
|
assertEquals(emptyList(), store.list("c1"))
|
||||||
assertEquals(1, store.list("c2").size)
|
assertEquals(1, store.list("c2").size)
|
||||||
@@ -67,23 +65,20 @@ class InMemoryWorkingMemoryStoreTest {
|
|||||||
fun `compact without summary drops tail and returns new max`() = runTest {
|
fun `compact without summary drops tail and returns new max`() = runTest {
|
||||||
val store = InMemoryWorkingMemoryStore()
|
val store = InMemoryWorkingMemoryStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
|
||||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||||
// dropFromOrderIdx=2 → удаляет всё >= 2 (то есть только Assistant "a1")
|
// dropFromOrderIdx=1 → удаляет всё >= 1 (то есть только Assistant "a1")
|
||||||
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
val newMax = store.compact(dropFromOrderIdx = 1, conversationId = "c1", summaryText = null)
|
||||||
assertEquals(1L, newMax)
|
assertEquals(0L, newMax)
|
||||||
val rows = store.list("c1")
|
val rows = store.list("c1")
|
||||||
assertEquals(2, rows.size)
|
assertEquals(1, rows.size)
|
||||||
assertEquals("a", (rows[0].entry as WorkingMemoryEntry.System).text)
|
assertEquals("u1", rows[0].entry.sourceMessageId)
|
||||||
assertEquals("u1", rows[1].entry.sourceMessageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `compact with summary replaces tail with synthetic Summary row`() = runTest {
|
fun `compact with summary replaces tail with synthetic Summary row`() = runTest {
|
||||||
val store = InMemoryWorkingMemoryStore()
|
val store = InMemoryWorkingMemoryStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
|
|
||||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||||
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S")))
|
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S")))
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# `:storage-sqlite` — SQLite реализация `:storage-core` (JVM-only)
|
||||||
|
|
||||||
|
## Что что это
|
||||||
|
|
||||||
|
Production persistence для `:standalone` на [SQLDelight](https://cashapp.github.io/sqldelight/):
|
||||||
|
|
||||||
|
- **messages** — append-only журнал с `conversation_id`, `created_at`.
|
||||||
|
- **working_memory** — rolling buffer последних 100 entries, типы
|
||||||
|
в JSON (`UserMessage / AssistantMessage / ToolExchange / SystemPrompt`).
|
||||||
|
- **conversations** — метаданные (id, title, model, timestamps).
|
||||||
|
- **reflections** — произвольные заметки ("I notice you often
|
||||||
|
prefer short replies").
|
||||||
|
- Промпт хранителя (`@mem0`) индексирован отдельно для быстрого
|
||||||
|
доступа.
|
||||||
|
|
||||||
|
Решает: стабильная, локальная, нулевая-настройка БД. Подходит и для
|
||||||
|
desktop-продакшена, и для Android, и для тестов (через Testcontainers).
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` подключает по умолчанию (`storage.db` = путь из
|
||||||
|
`AGENTIK_DB_PATH`).
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation("pw.binom.agentik:storage-sqlite:0.1.0")
|
||||||
|
implementation("pw.binom.agentik:storage-core:0.1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
val storage = SqliteStorageSystem.open(Path("agentik.db"))
|
||||||
|
val messages: MessageStore = storage.messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-storage-sqlite`.
|
||||||
|
|
||||||
|
Зависит от `app.cash.sqldelight:sqlite-driver:2.1.0` (через
|
||||||
|
`gradle/libs.versions.toml`).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :storage-sqlite:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: миграции (через `migrations/` каталог и SQLDelight
|
||||||
|
`*.sqm`), round-trip, race-conditions (concurrent append), paged
|
||||||
|
flow.
|
||||||
|
|
||||||
|
## Что в схеме (упрощённо)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, -- ISO Instant
|
||||||
|
kind TEXT NOT NULL, -- 'user', 'assistant', 'tool_call', 'tool_result'
|
||||||
|
body_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_messages_conv_time ON messages(conversation_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE working_memory (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
entry_id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
body_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная схема + миграции — в `src/jvmMain/sqldelight/`.
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Миграции 1.0+.
|
||||||
+1
-1
@@ -72,9 +72,9 @@ class SqliteWorkingMemoryStore(private val db: AgentikDatabase) : WorkingMemoryS
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||||
is WorkingMemoryEntry.System -> "system"
|
|
||||||
is WorkingMemoryEntry.User -> "user"
|
is WorkingMemoryEntry.User -> "user"
|
||||||
is WorkingMemoryEntry.Assistant -> "assistant"
|
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||||
|
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
|
||||||
is WorkingMemoryEntry.Summary -> "summary"
|
is WorkingMemoryEntry.Summary -> "summary"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user