Compare commits
73 Commits
0fdc12695e
..
16
| Author | SHA1 | Date | |
|---|---|---|---|
| f191387e99 | |||
| d1b4f897b7 | |||
| a81d92f489 | |||
| 3e583ac8ea | |||
| f878d1c79b | |||
| 266ec38c1b | |||
| e447525059 | |||
| 84f5fd84f3 | |||
| 639c7d1748 | |||
| 5f0e0da361 | |||
| acb4ee6186 | |||
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf | |||
| 68543357c2 | |||
| f946186ef5 | |||
| fb963bfb6b | |||
| 818f022ba3 | |||
| 0d1be42919 | |||
| 2d6cf89c52 | |||
| 8f85612665 | |||
| aef5083801 | |||
| 499db812ef | |||
| d6afc05c20 | |||
| acc7237e51 | |||
| bd65c29b48 | |||
| 2cc1d923b3 | |||
| 8f8f7f1020 | |||
| 3f260f3ac8 | |||
| d74af621d8 | |||
| 15f3952eba | |||
| a0b1209457 | |||
| a05e260457 | |||
| 7e66baf9e3 | |||
| 1134e32ea2 | |||
| e2f0e434d1 | |||
| 1f85cde1b8 | |||
| 8bb24dab3c | |||
| 7358175499 | |||
| 2d9ad526bb | |||
| dd7aec8df1 | |||
| bf2649a856 | |||
| 4ad59d5f5d | |||
| c140d0b758 | |||
| c486c7f9ab | |||
| 9d310c5fd0 | |||
| 741ad8963d | |||
| 2634e0e204 | |||
| ac5d209fce | |||
| 25771a0c33 | |||
| 78cbe9b463 | |||
| 65e05612a1 | |||
| 850ee99cb6 | |||
| b5b21d146a | |||
| ee0b9d8341 | |||
| 9d826a4e81 | |||
| db3c49099c | |||
| ddd9d076c1 | |||
| 7a47131f6f | |||
| 9196102f68 | |||
| 6b12dd2c5b | |||
| eed1ab9a17 | |||
| b27ac622b4 | |||
| 14b46087dd | |||
| 098c97c7bd | |||
| 4b8e5bb0bd | |||
| d75289ac56 | |||
| 05f7b8fd04 | |||
| 8f616f359f | |||
| 5ad972767d |
@@ -0,0 +1,82 @@
|
|||||||
|
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
|
||||||
|
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
|
||||||
|
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
||||||
|
#
|
||||||
|
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||||
|
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||||
|
# release.yml для требуемых переменных.
|
||||||
|
#
|
||||||
|
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||||
|
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
|
||||||
|
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
|
||||||
|
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
|
||||||
|
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||||
|
env:
|
||||||
|
LANG: C.UTF-8
|
||||||
|
LC_ALL: C.UTF-8
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-jvm:
|
||||||
|
name: JVM build + tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'adopt'
|
||||||
|
|
||||||
|
- name: Gradle cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
.gradle
|
||||||
|
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-agentik-
|
||||||
|
|
||||||
|
- name: Build + test (JVM only — самые быстрые таргеты)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew jvmTest \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
|
||||||
|
- name: Build :standalone shadowJar (smoke — запускаемый артефакт)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
./gradlew :standalone:shadowJar \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
|
test -f standalone/build/libs/standalone-*-all.jar \
|
||||||
|
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||||
|
|
||||||
|
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||||
|
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||||
|
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||||
|
#
|
||||||
|
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||||
|
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||||
|
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||||
|
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
|
||||||
|
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
|
||||||
|
# проверка сборки ограничивается test -f на jar (шаги выше).
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# PR / push-build. Прогоняет unit-тесты на JVM и линтер gradle-плагинов.
|
|
||||||
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
|
||||||
#
|
|
||||||
# Требуемые Gitea Action Secrets: нет (только gradle-cache).
|
|
||||||
# Опционально: GRADLE_DOWNLOAD_TOKEN — если хочется переиспользовать кэш между
|
|
||||||
# репами (через actions/cache + restore-keys).
|
|
||||||
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
|
|
||||||
+27
-111
@@ -1,12 +1,20 @@
|
|||||||
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||||
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine", а также
|
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||||
# собирает fatjar'ы запускаемых модулей и прикрепляет их к релизу как
|
|
||||||
# бинарные ассеты.
|
|
||||||
#
|
#
|
||||||
# Требуемые Gitea Action Variables:
|
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
|
||||||
# BINOM_REPO_URL — например http://nexus.xx/repository/caffeine/
|
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
|
||||||
# Требуемые Gitea Action Secrets:
|
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||||
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
# выполняется локально из исходников (или руками через `./gradlew
|
||||||
|
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||||
|
# отдельно от этого workflow.
|
||||||
|
#
|
||||||
|
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
|
||||||
|
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
|
||||||
|
# версия берётся из тега каждый раз.
|
||||||
|
#
|
||||||
|
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
|
||||||
|
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
|
||||||
|
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
|
||||||
name: release
|
name: release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -17,6 +25,15 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
|
||||||
|
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
|
||||||
|
# падает с "InvalidPathException: Malformed input or input contains unmappable
|
||||||
|
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
|
||||||
|
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||||
|
env:
|
||||||
|
LANG: C.UTF-8
|
||||||
|
LC_ALL: C.UTF-8
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-libraries:
|
publish-libraries:
|
||||||
name: Publish KMP libraries → caffeine Nexus
|
name: Publish KMP libraries → caffeine Nexus
|
||||||
@@ -26,108 +43,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup JDK 21
|
- name: Publish libraries (all KMP targets, all modules) to Nexus
|
||||||
uses: actions/setup-java@v4
|
uses: https://git.binom.pw/subochev/devops/publish@main
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
version: ${{ gitea.ref_name }}
|
||||||
distribution: 'adopt'
|
|
||||||
|
|
||||||
- name: Gradle cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
.gradle
|
|
||||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-agentik-
|
|
||||||
|
|
||||||
- name: Publish libraries (all KMP targets, all modules)
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
|
|
||||||
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
|
|
||||||
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
|
|
||||||
run: |
|
|
||||||
./gradlew \
|
|
||||||
"-Pversion=${GITEA_REF_NAME}" \
|
|
||||||
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
|
|
||||||
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
|
|
||||||
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
|
|
||||||
publish \
|
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
|
|
||||||
build-fatjars:
|
|
||||||
name: Build runnable fatjars
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup JDK 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
java-version: '21'
|
|
||||||
distribution: 'adopt'
|
|
||||||
|
|
||||||
- name: Gradle cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
.gradle
|
|
||||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-agentik-
|
|
||||||
|
|
||||||
- name: Build :standalone shadowJar
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
./gradlew :standalone:shadowJar \
|
|
||||||
-Pdisable-javadoc=true \
|
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
|
|
||||||
- name: Build :agentik-cli shadowJar
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
./gradlew :agentik-cli:shadowJar \
|
|
||||||
-Pdisable-javadoc=true \
|
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
|
|
||||||
- name: Build :agentik-tui shadowJar
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
./gradlew :agentik-tui:shadowJar \
|
|
||||||
-Pdisable-javadoc=true \
|
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
|
|
||||||
- name: Upload fatjars as release assets
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: agentik-fatjars
|
|
||||||
path: |
|
|
||||||
standalone/build/libs/standalone-all.jar
|
|
||||||
agentik-cli/build/libs/agentik-cli-all.jar
|
|
||||||
agentik-tui/build/libs/agentik-tui-all.jar
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 90
|
|
||||||
|
|
||||||
- name: Attach to release
|
|
||||||
uses: https://git.binom.pw/actions/forgejo-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
url: ${{ github.server_url }}
|
|
||||||
repo: ${{ github.repository }}
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
tag: ${{ github.ref_name }}
|
|
||||||
files: |
|
|
||||||
standalone/build/libs/standalone-all.jar
|
|
||||||
agentik-cli/build/libs/agentik-cli-all.jar
|
|
||||||
agentik-tui/build/libs/agentik-tui-all.jar
|
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ out/
|
|||||||
|
|
||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
|
# opencode CLI local config (per-machine, не коммитим)
|
||||||
|
config.json
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
|
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||||
|
.tasks/
|
||||||
|
|
||||||
# Runtime / test artifacts
|
# Runtime / test artifacts
|
||||||
agentik.db
|
agentik.db
|
||||||
agentik.db-shm
|
agentik.db-shm
|
||||||
|
|||||||
@@ -1,86 +1,154 @@
|
|||||||
# agentik
|
# agentik
|
||||||
|
|
||||||
Self-contained multi-module Kotlin Multiplatform агент с долговременной памятью,
|
Локальный stateful LLM-агент с persistent-памятью, инструментами и
|
||||||
персоной, навыками и HTTP-фасадом под `/agentik`. Состоит из библиотечных модулей
|
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
|
||||||
(KMP, опубликованных в Nexus `caffeine`) и трёх запускаемых артефактов.
|
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
|
||||||
|
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
|
||||||
|
Qwen) через ONNX/Native-runtime.
|
||||||
|
|
||||||
## Запускаемые модули
|
## Что внутри
|
||||||
|
|
||||||
| Модуль | Что делает | Артефакт | Таргеты |
|
```
|
||||||
|---|---|---|---|
|
agentik/
|
||||||
| [`:standalone`](standalone/README.md) | HTTP-сервер со всеми транспортами (AG-UI / A2A / `:proto`), SQLite, памятью, скилами, SOUL, MCP | `standalone-all.jar` (≈250 MB) | JVM |
|
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
|
||||||
| [`:agentik-cli`](agentik-cli/README.md) | REPL-клиент к `/agentik` со slash-командами | `agentik-cli-all.jar` (≈8 MB) | JVM |
|
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
|
||||||
| [`:agentik-tui`](agentik-tui/README.md) | Compose-style TUI-клиент (Mosaic) к `/agentik` | `agentik-tui-all.jar` (≈10 MB) | JVM + macosX64/macosArm64/linuxX64/linuxArm64/mingwX64 |
|
├── 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 / ...)
|
||||||
|
│ (исторический, см. journal-api / context-api / reflection-api ниже)
|
||||||
|
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
|
||||||
|
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
|
||||||
|
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||||
|
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||||
|
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||||
|
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||||
|
```
|
||||||
|
|
||||||
## Библиотеки
|
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||||
|
(см. "Модули" ниже).
|
||||||
|
|
||||||
Все библиотеки — **KMP (jvm + 8 native)**, опубликованы в Nexus-репо `caffeine`
|
## Quickstart
|
||||||
под группой `pw.binom.agentik`.
|
|
||||||
|
|
||||||
### Протокол и транспорт
|
### 1. Скачать fatjar
|
||||||
- [`:proto`](proto/README.md) — `Agent` / `Conversation` / `Message` / `Event`, типы без сетевой логики. **Stateful** — клиент шлёт только новый message, агент владеет историей.
|
|
||||||
- [`:server`](server/README.md) — Ktor-фасад, экспонирующий `:proto.Agent` под `/agentik` (HTTP+JSON+SSE).
|
|
||||||
- [`:client`](client/README.md) — Ktor-клиент, превращающий HTTP `/agentik` обратно в `Agent`/`Conversation`.
|
|
||||||
|
|
||||||
### Память
|
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||||
- [`:memory-api`](memory-api/README.md) — контракт: `MemoryStore`, `MemoryNote`, `MemoryPrefetcher`, `MemoryReviewer`, `MemoryTools`.
|
tag-релизах, либо соберите из исходников:
|
||||||
- [`:memory-md`](memory-md/README.md) — Hermes-style реализация поверх §-файлов (`user.md`/`world.md`/`preference.md`).
|
|
||||||
- [`:memory-vector`](memory-vector/README.md) — JVector (ANN) + SQLite + эмбеддинги (HTTP/SIGLIP on-device).
|
|
||||||
|
|
||||||
### Хранилище
|
```bash
|
||||||
- [`:storage-core`](storage-core/README.md) — `ConversationStore` / `MessageStore` / `WorkingMemoryStore` / `ReflectionStore`.
|
git clone https://git.binom.pw/subochev/agentik
|
||||||
- [`:storage-inmemory`](storage-inmemory/README.md) — in-memory реализация (для тестов и embedded).
|
cd agentik
|
||||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLDelight реализация (прод-бэкенд).
|
./gradlew :standalone:shadowJar
|
||||||
|
```
|
||||||
|
|
||||||
### Логика
|
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||||
- [`:skills`](skills/README.md) — opencode-style `SKILL.md` / `*.yaml` парсер + рендер в system prompt.
|
зависит от LLM-backend'а).
|
||||||
- [`:agent-toolsets`](agent-toolsets/README.md) — реестр тулов + `enable_toolset`/`disable_toolset` диспетчер.
|
|
||||||
|
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=openai \
|
||||||
|
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||||
|
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||||
|
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIK_LLM_BACKEND=google \
|
||||||
|
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||||
|
```
|
||||||
|
|
||||||
|
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||||
|
|
||||||
|
## Подключиться
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
- Запускаемые:
|
||||||
|
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||||
|
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
|
||||||
|
- Библиотеки (контракты и реализации):
|
||||||
|
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||||
|
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||||
|
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||||
|
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||||
|
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||||
|
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||||
|
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||||
|
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
|
||||||
|
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
|
||||||
|
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
|
||||||
|
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||||
|
|
||||||
## Где смотреть версии
|
## Где смотреть версии
|
||||||
|
|
||||||
- `gradle.properties` → `version=0.1.0` (текущая разрабатываемая)
|
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||||
- Релизы: `https://git.binom.pw/subochev/agentik/releases`
|
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||||
- Опубликованные артефакты: Nexus-репозиторий `caffeine`
|
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||||
(`http://nexus.xx/repository/caffeine/pw/binom/agentik/`)
|
|
||||||
|
|
||||||
При подключении библиотек используйте одну и ту же `version` (`VERSION` в Gradle
|
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||||
зависимостях). Все артефакты синхронизированы и совместимы по ABI в пределах
|
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||||
одной версии.
|
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||||
|
тега и публикует.
|
||||||
|
|
||||||
## Публикация (CI/CD)
|
## Публикация
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` — публикует все KMP-таргеты всех модулей в
|
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||||
Nexus-репо `caffeine` при создании Gitea Release. Версия артефактов берётся из
|
Параметры через:
|
||||||
имени тега (`git tag v0.1.0` → `pw.binom.agentik:*:0.1.0`).
|
|
||||||
|
|
||||||
```bash
|
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||||
# Создать релиз:
|
- `binom.repo.user`
|
||||||
git tag v0.1.0 && git push --tags
|
- `binom.repo.password`
|
||||||
# → Gitea → Releases → New Release → выбрать тег → Publish
|
|
||||||
# → CI публикует в Nexus (нужны секреты 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`.
|
||||||
|
|
||||||
```bash
|
## CI/CD
|
||||||
# Всё
|
|
||||||
./gradlew build
|
|
||||||
|
|
||||||
# Только JVM-тесты всех модулей
|
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||||
./gradlew jvmTest
|
|
||||||
|
|
||||||
# Только fatjar запускаемых модулей
|
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
|
||||||
./gradlew :standalone:shadowJar :agentik-cli:shadowJar :agentik-tui:shadowJar
|
shadowjar'ов.
|
||||||
|
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||||
|
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||||
|
|
||||||
# Опубликовать локально в mavenLocal (~/.m2)
|
## Что отличает от других агентских фреймворков
|
||||||
./gradlew publishToMavenLocal
|
|
||||||
|
|
||||||
# Опубликовать в Nexus (нужны креды)
|
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||||
./gradlew publish -Pversion=0.2.0 \
|
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||||
-Pbinom.repo.url=http://nexus.xx/repository/caffeine/ \
|
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||||
-Pbinom.repo.user=USER -Pbinom.repo.password=PASS
|
Один fatjar — три API.
|
||||||
```
|
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||||
|
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||||
|
Desktop / CLI.
|
||||||
|
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||||
|
потери контекста, если пользователь нажал Ctrl-C во время
|
||||||
|
долгого tool-вызова.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Apache-2.0 — см. [LICENSE](LICENSE) (если есть).
|
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Участие в проекте
|
||||||
|
|
||||||
|
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||||
|
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||||
|
изменении API.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// :proto — read-only Agent interface, который MutableAgent расширяет.
|
||||||
|
// Через api(), иначе downstream-impl ChatAgent не сможет
|
||||||
|
// override suspend-методы Agent.
|
||||||
|
api(project(":proto"))
|
||||||
|
// :memory-api — typealias ConversationTurn на memory-api одноимённый
|
||||||
|
// класс, иначе пер-конво компоненты (skill mining, reflection) не
|
||||||
|
// смогут передать его в SkillMiner.mine() напрямую.
|
||||||
|
api(project(":memory-api"))
|
||||||
|
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
|
||||||
|
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
|
||||||
|
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
|
||||||
|
api(libs.litert.api)
|
||||||
|
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
|
||||||
|
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
|
||||||
|
// listener) — оставлен как api, чтобы downstream не забывал объявить.
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нашлёпка поверх [MutableAgent].
|
||||||
|
*
|
||||||
|
* Компонент сам регистрирует в агенте свои capability-провайдеры
|
||||||
|
* при [install] и снимает их при [uninstall]. Агент не знает заранее
|
||||||
|
* ни о структуре компонента, ни о его провайдерах — это просто
|
||||||
|
* хук для свободной композиции.
|
||||||
|
*
|
||||||
|
* Ktor-style API:
|
||||||
|
* ```
|
||||||
|
* val agent = ChatAgent(...)
|
||||||
|
* .install(SkillComponent(store, miner))
|
||||||
|
* .install(ReflectionComponent(reflectionStore, reflector))
|
||||||
|
* .install(MemoryComponent(memorySystem))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* - [install] **синхронен**: компонент добавляет свои провайдеры в
|
||||||
|
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
|
||||||
|
* фоновые корутины — компонент запускает их через свой собственный
|
||||||
|
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
|
||||||
|
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
|
||||||
|
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
|
||||||
|
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
|
||||||
|
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
|
||||||
|
*/
|
||||||
|
interface Component {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается агентом при [MutableAgent.install].
|
||||||
|
*
|
||||||
|
* Типичные действия: добавить [SystemPromptProvider] в
|
||||||
|
* `agent.systemProviders`, добавить [ToolProvider] в
|
||||||
|
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
|
||||||
|
*/
|
||||||
|
fun install(agent: MutableAgent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается агентом при [MutableAgent.uninstall] или при
|
||||||
|
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
|
||||||
|
* которые добавил в [install], и остановить фоновые джобы.
|
||||||
|
*/
|
||||||
|
fun uninstall(agent: MutableAgent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
|
||||||
|
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
|
||||||
|
*
|
||||||
|
* [MutableAgent] при создании/закрытии разговора вызывает
|
||||||
|
* [attachConversation] / [detachConversation] на каждом компоненте,
|
||||||
|
* реализующем этот интерфейс. Внутри компонент хранит
|
||||||
|
* [ConversationHandle] (или контекст вокруг него) и подписывается на
|
||||||
|
* нужные события.
|
||||||
|
*
|
||||||
|
* Компонент без [ConversationAware] остаётся чисто agent-level — он
|
||||||
|
* не получает per-conversation хуков.
|
||||||
|
*/
|
||||||
|
interface ConversationAware {
|
||||||
|
fun attachConversation(handle: ConversationHandle)
|
||||||
|
fun detachConversation(handle: ConversationHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальное окно в разговор, которое компонент видит через
|
||||||
|
* [ConversationAware]. Содержит только то, что нужно большинству
|
||||||
|
* per-conversation компонентов:
|
||||||
|
* - идентификатор (для подписки на события),
|
||||||
|
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
|
||||||
|
* - последние N turns (для LlmReflector / SkillMiner).
|
||||||
|
*
|
||||||
|
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
|
||||||
|
* чтобы компонент не лез в чужие обязанности.
|
||||||
|
*/
|
||||||
|
interface ConversationHandle : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
val isTemporal: Boolean
|
||||||
|
|
||||||
|
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
|
||||||
|
suspend fun recentTurns(limit: Int): List<ConversationTurn>
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
|
||||||
|
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
|
||||||
|
* компоненты (skill mining, reflection) могли передавать его напрямую
|
||||||
|
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
|
||||||
|
* конвертации.
|
||||||
|
*/
|
||||||
|
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настраиваемая версия [Agent]: расширяет публичный contract агента
|
||||||
|
* install/uninstall-механикой компонентов ([Component]).
|
||||||
|
*
|
||||||
|
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
|
||||||
|
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
|
||||||
|
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
|
||||||
|
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
|
||||||
|
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
|
||||||
|
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
|
||||||
|
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
|
||||||
|
*
|
||||||
|
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
|
||||||
|
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
|
||||||
|
* позже, в v1 держим KISS.
|
||||||
|
*/
|
||||||
|
interface MutableAgent : Agent {
|
||||||
|
/**
|
||||||
|
* Провайдеры секций system prompt, регистрируются компонентами через [install].
|
||||||
|
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
|
||||||
|
* system prompt конкретной беседы; возвращает `null`, если у него нет
|
||||||
|
* релевантной секции для данного контекста.
|
||||||
|
*
|
||||||
|
* Изменяется **только внутри `Component.install(this)` /
|
||||||
|
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
|
||||||
|
* напрямую в список не лезет.
|
||||||
|
*/
|
||||||
|
val systemProviders: MutableList<SystemPromptProvider>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдеры tools, регистрируются компонентами через [install].
|
||||||
|
* [ToolProvider.tools] вызывается при формировании набора тулов
|
||||||
|
* для конкретной беседы; компонент решает сам, какие тулы отдавать
|
||||||
|
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
|
||||||
|
*/
|
||||||
|
val toolProviders: MutableList<ToolProvider>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает [component] в агент: `component.install(this)` +
|
||||||
|
* агент запоминает компонент, чтобы при [close] корректно его снять.
|
||||||
|
*
|
||||||
|
* Возвращает `this` — для fluent-цепочек:
|
||||||
|
* ```
|
||||||
|
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun install(component: Component): MutableAgent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает [component]: `component.uninstall(this)` + забывает.
|
||||||
|
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
|
||||||
|
*/
|
||||||
|
fun uninstall(component: Component): MutableAgent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оповещает все установленные компоненты, реализующие [ConversationAware],
|
||||||
|
* о появлении нового разговора. Компонент может подписаться на события,
|
||||||
|
* запустить фоновые задачи, проиндексировать turns и т.п.
|
||||||
|
*/
|
||||||
|
fun attachConversation(handle: ConversationHandle)
|
||||||
|
|
||||||
|
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
|
||||||
|
fun detachConversation(handle: ConversationHandle)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Освобождает ресурсы агента и снимает все установленные компоненты
|
||||||
|
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
|
||||||
|
* первым). Idempotent.
|
||||||
|
*/
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер одной секции system prompt конкретной беседы.
|
||||||
|
*
|
||||||
|
* Вызывается [MutableAgent] при каждом построении system prompt
|
||||||
|
* (на старте беседы и после значимых изменений контекста). Возвращает
|
||||||
|
* либо markdown-строку секции (будет вставлена в system prompt в порядке
|
||||||
|
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
|
||||||
|
* либо `null`, если у провайдера нет релевантной секции для данного
|
||||||
|
* контекста (например, skill-каталог пуст).
|
||||||
|
*
|
||||||
|
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
|
||||||
|
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
|
||||||
|
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
|
||||||
|
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
|
||||||
|
* страх и риск, **не** рекомендуется в v1).
|
||||||
|
*/
|
||||||
|
fun interface SystemPromptProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
|
||||||
|
*
|
||||||
|
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
|
||||||
|
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
|
||||||
|
* если провайдер хочет делать per-conversation разделение, он может.
|
||||||
|
*/
|
||||||
|
fun getSection(conversationId: String): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pw.binom.agentik.agent
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Провайдер набора тулов конкретной беседы.
|
||||||
|
*
|
||||||
|
* Вызывается [MutableAgent] при формировании списка тулов, доступных
|
||||||
|
* модели в данной беседе (на старте и при пересборке после существенных
|
||||||
|
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
|
||||||
|
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
|
||||||
|
* из `describe()` / `invoke()` того же объекта.
|
||||||
|
*
|
||||||
|
* Не-suspend: типичная реализация строит список тулов из in-memory state
|
||||||
|
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
|
||||||
|
* к state компонент использует свой собственный scope и кэш.
|
||||||
|
*
|
||||||
|
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
|
||||||
|
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
|
||||||
|
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
|
||||||
|
* лишним слоем и удалён.
|
||||||
|
*/
|
||||||
|
fun interface ToolProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает список тулов, доступных модели в беседе [conversationId].
|
||||||
|
*
|
||||||
|
* Провайдер может делать per-conversation фильтрацию (например, скрывать
|
||||||
|
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
|
||||||
|
* пустой список.
|
||||||
|
*/
|
||||||
|
fun getTools(conversationId: String): List<LiteTool>
|
||||||
|
}
|
||||||
+64
-50
@@ -1,73 +1,87 @@
|
|||||||
# :agent-toolsets — `pw.binom.agentik.toolsets`
|
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
|
||||||
|
|
||||||
**Ядро механики toolsets: `ToolsetRegistry`, `ToolsetDispatchPolicy`,
|
## Что это
|
||||||
встроенные тулы `enable_toolset` / `disable_toolset`, `SyncLiteTool` базовый
|
|
||||||
класс.**
|
|
||||||
KMP, не зависит от `:standalone`, переиспользуем в Android и в любом другом
|
|
||||||
LiteTool-агенте.
|
|
||||||
|
|
||||||
## Какую проблему решает
|
Ядро системы tools для LLM-агента:
|
||||||
|
|
||||||
В проде у агента может быть **сотня** инструментов (MCP-серверы, кастомные
|
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||||
тулы, встроенные операции). Слать их все в каждый LLM-запрос:
|
(`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+ диалогов.
|
||||||
|
|
||||||
1. **Раздувает контекст** — описание тула ~50–200 токенов × 100 тулов = 20K токенов
|
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||||
в system prompt без пользы.
|
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||||
2. **Увеличивает latency** — модель тратит время на выбор из длинного списка.
|
IM-фронтендах (CLI, TUI, IRC, web).
|
||||||
3. **Снижает качество** — модель путается между похожими названиями.
|
|
||||||
|
|
||||||
Toolsets группируют тулы **по домену** (`filesystem`, `network`, `devops`, …).
|
## Где используется
|
||||||
Активированы только 2-3 одновременно. `enable_toolset("filesystem")` —
|
|
||||||
включает целую группу одним обращением; тулы появляются в system prompt +
|
|
||||||
регистрируются как вызываемые. `disable_toolset(...)` — убирает.
|
|
||||||
|
|
||||||
## Архитектура
|
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||||
|
(memory / skills / files / web), фильтрует через
|
||||||
|
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||||
|
|
||||||
|
## Как подключить
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
interface Toolset {
|
commonMain.dependencies {
|
||||||
val name: String // "filesystem"
|
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||||
val title: String // "File operations"
|
|
||||||
val enabled: Boolean // текущее состояние
|
|
||||||
suspend fun enabledTools(context: ToolsetContext): List<LiteTool>
|
|
||||||
suspend fun systemPromptSection(context: ToolsetContext): String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToolsetRegistry {
|
class MyToolset : Toolset {
|
||||||
fun register(toolset: Toolset)
|
override val name = "my"
|
||||||
fun list(): List<Toolset>
|
override val description = "Custom user-defined tools"
|
||||||
suspend fun enable(name: String): Boolean
|
override val tools = listOf(myTool1, myTool2)
|
||||||
suspend fun disable(name: String): Boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ToolsetDispatchPolicy {
|
val dispatcher = ToolDispatcher(
|
||||||
fun buildDispatch(): DispatchPolicy // подаётся в LiteLlm
|
toolsets = listOf(MemoryTools(memory), MyToolset()),
|
||||||
}
|
enabled = setOf("memory", "my"),
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Встроенные тулы — `EnableToolsetTool` / `DisableToolsetTool` /
|
## Версии
|
||||||
`SystemPromptToolsetSection` — дают LLM самой управлять составом инструментов.
|
|
||||||
База для кастомных тулов — `SyncLiteTool` (обёртка над `LiteTool`,
|
|
||||||
синхронная `execute(args): String`).
|
|
||||||
|
|
||||||
## Подключение
|
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
|
||||||
|
|
||||||
|
## Как пишется tool
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
commonMain {
|
data object EchoTool : Tool {
|
||||||
implementation("pw.binom.agentik:agent-toolsets:$version")
|
override val name = "echo"
|
||||||
// Транзитивно: :storage-core (для ToolsetContext) + :proto
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Где смотреть версии
|
## Тесты
|
||||||
|
|
||||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
```
|
||||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
./gradlew :agent-toolsets:allTests
|
||||||
|
|
||||||
## Сборка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew :agent-toolsets:build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
KMP-таргеты — полный набор. Зависимости — `:storage-core` + `:proto` +
|
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||||
`kotlinx-coroutines` + `kotlinx-serialization`.
|
budget exhaustion, registry filter, parallel dispatch.
|
||||||
|
|
||||||
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
|
- Никакого persistent storage. Опирается на контракт `ContextStore`
|
||||||
|
(см. `:storage-core`).
|
||||||
|
|
||||||
|
## Текущий статус
|
||||||
|
|
||||||
|
Используется продакшеном. Реализует полную спецификацию из
|
||||||
|
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||||
|
log, rolling buffer, partial-state persistence.
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
api(project(":journal-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
|
api(project(":agent-api"))
|
||||||
|
|
||||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||||||
|
api(libs.litert.tools.kotlinx.serialization)
|
||||||
|
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
|
|||||||
+12
-12
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<DisableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Deactivate a toolset by name. Its tools become unavailable.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: DisableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
// Единообразный ответ независимо от текущего состояния.
|
|
||||||
registry.deactivate(name)
|
registry.deactivate(name)
|
||||||
return "Toolset '$name' deactivated."
|
return "Toolset '$name' deactivated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем активные (что можно деактивировать)
|
|
||||||
val actives = registry.activeNames()
|
val actives = registry.activeNames()
|
||||||
return if (actives.isEmpty()) {
|
return if (actives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets to deactivate."
|
"Toolset '$name' not found. No toolsets to deactivate."
|
||||||
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class DisableArgs(val name: String)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME: String = "disable_toolset"
|
const val NAME: String = "disable_toolset"
|
||||||
|
|
||||||
internal val DESCRIBE: String = """
|
|
||||||
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-26
@@ -1,8 +1,6 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<EnableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Activate a toolset by name to access its tools.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: EnableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
val wasActive = registry.isActive(name)
|
val wasActive = registry.isActive(name)
|
||||||
registry.activate(name)
|
registry.activate(name)
|
||||||
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем доступные к активации (inactives)
|
|
||||||
val inactives = registry.inactiveNames()
|
val inactives = registry.inactiveNames()
|
||||||
return if (inactives.isEmpty()) {
|
return if (inactives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets available for activation."
|
"Toolset '$name' not found. No toolsets available for activation."
|
||||||
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class EnableArgs(val name: String)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME: String = "enable_toolset"
|
const val NAME: String = "enable_toolset"
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
|
|
||||||
* Соответствует litert-kmp формату LiteTool.describe().
|
|
||||||
*/
|
|
||||||
internal val DESCRIBE: String = """
|
|
||||||
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
|
|
||||||
* Возвращает null если отсутствует или не строка.
|
|
||||||
*/
|
|
||||||
internal fun parseName(argsJson: String): String? = runCatching {
|
|
||||||
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
|
|
||||||
}.getOrNull()
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
|
|||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
import pw.binom.litert.tools.kotlinx.serialization.liteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
* Обёртка из suspend-handler'а в синхронный [LiteTool].
|
||||||
*
|
*
|
||||||
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||||
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||||
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
|
|||||||
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||||
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||||
*
|
*
|
||||||
|
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
|
||||||
|
* который генерит JSON-схему из `@Serializable Args` через
|
||||||
|
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
|
||||||
|
* тулов, чьи описания не получается выразить через `Args` (например, динамические
|
||||||
|
* JSON Schema, приходящие со стороны).
|
||||||
|
*
|
||||||
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||||
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||||
*/
|
*/
|
||||||
internal class SyncLiteTool(
|
internal class SyncLiteTool(
|
||||||
|
override val name: String,
|
||||||
private val describeJson: String,
|
private val describeJson: String,
|
||||||
private val handler: suspend (String) -> String,
|
private val handler: suspend (String) -> String,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
|
||||||
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
* JSON-схема генерится автоматически из `Args.descriptor`,
|
||||||
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
* а сырая строка аргументов десериализуется в типизированный [Args].
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* ```
|
||||||
|
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
|
||||||
|
* suspendBlock(args) // MyArgs уже распарсен
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
|
||||||
|
* его sync-handler запускает наш suspend-handler в [runBlocking].
|
||||||
*/
|
*/
|
||||||
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
|
inline fun <reified Args> liteToolSuspend(
|
||||||
SyncLiteTool(describeJson, handler)
|
name: String,
|
||||||
|
description: String = "",
|
||||||
|
noinline handler: suspend (Args) -> String,
|
||||||
|
): LiteTool = liteTool<Args>(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
) { args ->
|
||||||
|
runBlocking { handler(args) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = false
|
||||||
|
coerceInputValues = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import pw.binom.agentik.agent.Component
|
||||||
|
import pw.binom.agentik.agent.MutableAgent
|
||||||
|
import pw.binom.agentik.agent.SystemPromptProvider
|
||||||
|
import pw.binom.agentik.agent.ToolProvider
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключает механику toolsets к агенту:
|
||||||
|
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
|
||||||
|
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
|
||||||
|
* ими переключает состояние.
|
||||||
|
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
|
||||||
|
* X.tools становятся видны через [ToolProvider.getTools] уже на
|
||||||
|
* следующем turn'е.
|
||||||
|
* - Секция системного промпта — список активных/неактивных тулсетов,
|
||||||
|
* чтобы модель знала что включено.
|
||||||
|
*
|
||||||
|
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
|
||||||
|
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
|
||||||
|
*
|
||||||
|
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
|
||||||
|
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
|
||||||
|
*/
|
||||||
|
class ToolsetComponent(
|
||||||
|
private val contributions: List<ToolsetContribution>,
|
||||||
|
) : Component {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
|
||||||
|
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
|
||||||
|
*/
|
||||||
|
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
|
||||||
|
|
||||||
|
private var provider: ToolsetToolProvider? = null
|
||||||
|
|
||||||
|
override fun install(agent: MutableAgent) {
|
||||||
|
val p = ToolsetToolProvider(registry, contributions)
|
||||||
|
agent.toolProviders.add(p)
|
||||||
|
provider = p
|
||||||
|
agent.systemProviders.add(ToolsetSystemProvider(registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uninstall(agent: MutableAgent) {
|
||||||
|
provider?.let { agent.toolProviders.remove(it) }
|
||||||
|
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
|
||||||
|
* - `enable_toolset` / `disable_toolset` — всегда.
|
||||||
|
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
|
||||||
|
*
|
||||||
|
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
|
||||||
|
* состояние после `enable_toolset` уже на следующем turn'е.
|
||||||
|
*/
|
||||||
|
class ToolsetToolProvider(
|
||||||
|
private val registry: ToolsetRegistry,
|
||||||
|
private val contributions: List<ToolsetContribution>,
|
||||||
|
) : ToolProvider {
|
||||||
|
|
||||||
|
override fun getTools(conversationId: String): List<LiteTool> = buildList {
|
||||||
|
add(EnableToolsetTool(registry).tool)
|
||||||
|
add(DisableToolsetTool(registry).tool)
|
||||||
|
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
|
||||||
|
// версия (lock-free snapshot), потому что `getTools` вызывается
|
||||||
|
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
|
||||||
|
// через Mutex в реестре (все мутации — через activate/deactivate).
|
||||||
|
val active = runBlockingSnapshot()
|
||||||
|
contributions.filter { it.name in active }.forEach { c ->
|
||||||
|
c.tools.forEach { add(it.tool) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимает снимок активных имён без suspend-блокировки.
|
||||||
|
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
|
||||||
|
* вычислить через прямой snapshot — для простоты используем runBlocking.
|
||||||
|
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
|
||||||
|
*/
|
||||||
|
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
|
||||||
|
registry.activeNames().toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Секция системного промпта с описанием доступных тулсетов:
|
||||||
|
* - `*active*` — что уже подключено.
|
||||||
|
* - `*inactive*` — что доступно через `enable_toolset`.
|
||||||
|
*/
|
||||||
|
class ToolsetSystemProvider(
|
||||||
|
private val registry: ToolsetRegistry,
|
||||||
|
) : SystemPromptProvider {
|
||||||
|
override fun getSection(conversationId: String): String {
|
||||||
|
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
|
||||||
|
val all = registry.all()
|
||||||
|
val active = all.filter { it.name in activeNames }
|
||||||
|
val inactive = all.filter { it.name !in activeNames }
|
||||||
|
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
|
|||||||
* Контекст, который тулсеты получают при активации.
|
* Контекст, который тулсеты получают при активации.
|
||||||
*
|
*
|
||||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||||
* могли читать/писать сообщения и память.
|
* могли читать/писать сообщения и память.
|
||||||
*
|
*
|
||||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||||
|
|||||||
+5
-12
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
|||||||
class DisableToolsetToolTest {
|
class DisableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
assertEquals(false, reg.isActive("media"))
|
assertEquals(false, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
|
|||||||
))
|
))
|
||||||
reg.activate("a")
|
reg.activate("a")
|
||||||
reg.activate("b")
|
reg.activate("b")
|
||||||
val r = disable.invoke("""{"name":"unknown"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("a", "x", emptyList()),
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
))
|
))
|
||||||
val r = disable.invoke("""{"name":"unknown"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing name argument returns error message`() = runTest {
|
|
||||||
val (disable, _) = harness(emptyList())
|
|
||||||
val r = disable.invoke("""{}""")
|
|
||||||
assertEquals("missing required argument 'name'", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-11
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
|||||||
class EnableToolsetToolTest {
|
class EnableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
|
|||||||
val (enable, reg) = harness(listOf(
|
val (enable, reg) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
||||||
))
|
))
|
||||||
val r = enable.invoke("""{"name":"media"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' activated.", r)
|
assertEquals("Toolset 'media' activated.", r)
|
||||||
assertEquals(true, reg.isActive("media"))
|
assertEquals(true, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = enable.invoke("""{"name":"media"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' already active.", r)
|
assertEquals("Toolset 'media' already active.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("b", "y", emptyList()),
|
ToolsetContribution("b", "y", emptyList()),
|
||||||
ToolsetContribution("c", "z", emptyList()),
|
ToolsetContribution("c", "z", emptyList()),
|
||||||
))
|
))
|
||||||
val r = enable.invoke("""{"name":"unknown"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("a", "x", emptyList()),
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("a")
|
reg.activate("a")
|
||||||
val r = enable.invoke("""{"name":"unknown"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||||
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing name argument returns error message`() = runTest {
|
|
||||||
val (enable, _) = harness(emptyList())
|
|
||||||
val r = enable.invoke("""{}""")
|
|
||||||
assertEquals("missing required argument 'name'", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
|
|||||||
class ToolsetDispatchPolicyTest {
|
class ToolsetDispatchPolicyTest {
|
||||||
|
|
||||||
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = response
|
override fun invoke(arguments: String) = response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
|
|||||||
class ToolsetRegistryTest {
|
class ToolsetRegistryTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||||
|
override val name: String = name
|
||||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok:$name"
|
override fun invoke(arguments: String) = "ok:$name"
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-52
@@ -1,76 +1,177 @@
|
|||||||
# :agentik-cli — JVM CLI клиент к /agentik
|
# `:agentik-cli` — one-shot CLI клиент к `/agentik`
|
||||||
|
|
||||||
REPL-клиент к запущенному `:standalone`-серверу на базе `:client`. **JVM-only**
|
## Что это
|
||||||
(JLine требует termios + `java.io.File`); для desktop-альтернативы — `:agentik-tui`.
|
|
||||||
|
|
||||||
## Сборка
|
**One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
|
||||||
|
`:standalone` через `:client` по HTTP+SSE. Один вызов — одна команда:
|
||||||
|
стрим ответа `send` идёт в stdout построчно, никакого embedded-REPL.
|
||||||
|
|
||||||
|
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
|
||||||
|
не поднимая отдельную TUI-сессии.
|
||||||
|
|
||||||
|
## Платформы
|
||||||
|
|
||||||
|
| Платформа | Артефакт | Размер | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `jvm` (JRE 21) | `*-all.jar` | ~7 МБ | ✓ собирается и работает |
|
||||||
|
| `linuxX64` | `.kexe` | ~5 МБ | ✓ собирается и работает на этом хосте |
|
||||||
|
| `macosX64` | `.kexe` | — | собирается на macOS-раннере |
|
||||||
|
| `macosArm64` | `.kexe` | — | собирается на macOS-arm64-раннере |
|
||||||
|
| `mingwX64` | `.exe` | ~6 МБ | ✓ собирается (cross-compile с Linux) |
|
||||||
|
| `linuxArm64` | — | — | **нет** — kotlinx-cli 0.3.6 не публикует klib для linuxArm64 |
|
||||||
|
| `iOS` | — | — | нет смысла на iOS |
|
||||||
|
|
||||||
|
## Подкоманды
|
||||||
|
|
||||||
|
```
|
||||||
|
agentik-cli <command> [args...]
|
||||||
|
|
||||||
|
Команды верхнего уровня:
|
||||||
|
conv <subcommand> операции над диалогами (см. ниже)
|
||||||
|
msgs <id> [--limit N] показать сообщения
|
||||||
|
send <id> <text...> отправить ход, стримит response-события в stdout
|
||||||
|
interrupt <id> прервать текущий ход
|
||||||
|
info показать конфиг (server URL + agent id)
|
||||||
|
|
||||||
|
Подкоманды `conv`:
|
||||||
|
conv ls список диалогов
|
||||||
|
conv new [--temp] создать диалог, печатает id
|
||||||
|
conv show <id> метаданные диалога
|
||||||
|
conv delete <id> удалить диалог
|
||||||
|
conv rename <id> <title> переименовать
|
||||||
|
```
|
||||||
|
|
||||||
|
`--server URL` и `--id ID` (env: `AGENTIK_SERVER`, `AGENTIK_AGENT_ID`)
|
||||||
|
задаются **после** имени subcommand'а — kotlinx.cli не шарит опции
|
||||||
|
родителя в subcommand. Примеры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agentik-cli conv ls --server http://192.168.76.166:8080/agentik
|
||||||
|
agentik-cli conv new --server http://localhost:8080/agentik
|
||||||
|
agentik-cli send --server http://localhost:8080/agentik conv-abc "привет"
|
||||||
|
agentik-cli info # через AGENTIK_SERVER env-переменную
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
### JVM (fatjar)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :agentik-cli:shadowJar
|
./gradlew :agentik-cli:shadowJar
|
||||||
# Результат: agentik-cli/build/libs/agentik-cli-all.jar (~8 MB)
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
|
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Также доступен через Maven Central Nexus (`caffeine` репо) — см. релизы:
|
### Native linuxX64
|
||||||
`https://git.binom.pw/subochev/agentik/releases`. После публикации нового
|
|
||||||
тега jar появляется как `pw.binom.agentik:agentik-cli:VERSION` (artifact + classifier `all`).
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# По умолчанию — http://localhost:8080/agentik
|
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
|
||||||
java -jar agentik-cli-all.jar
|
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
|
||||||
|
|
||||||
# К другому серверу
|
|
||||||
java -jar agentik-cli-all.jar --server http://192.168.76.166:8080/agentik
|
|
||||||
|
|
||||||
# Без восстановления последней беседы
|
|
||||||
java -jar agentik-cli-all.jar --no-history
|
|
||||||
|
|
||||||
# В конкретной беседе
|
|
||||||
java -jar agentik-cli-all.jar --id <conversation-uuid>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Альтернативно: `AGENTIK_SERVER` env-переменная с тем же эффектом, что и `--server`.
|
### Native macOS / Windows
|
||||||
|
|
||||||
## Slash-команды внутри REPL
|
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
|
||||||
|
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
|
||||||
|
кросс-компиляцию.
|
||||||
|
|
||||||
| Команда | Алиасы | Действие |
|
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
|
||||||
|---|---|---|
|
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
|
||||||
| `/help` | `/?` | список команд + клавиш |
|
раннере Gitea Actions.
|
||||||
| `/new <title?>` | `/n` | создать новый диалог |
|
|
||||||
| `/list` | `/ls` | показать все диалоги |
|
|
||||||
| `/switch <id>` | `/sw <id>` | переключиться на диалог |
|
|
||||||
| `/rename <title>` | `/mv <title>` | переименовать текущий |
|
|
||||||
| `/delete <id?>` | `/rm <id?>` | удалить (без id — текущий) |
|
|
||||||
| `/interrupt` | `/stop`, `/cancel` | прервать активный ход |
|
|
||||||
| `/history` | `/h`, `/hist` | backfill истории через `getMessages` |
|
|
||||||
| `/pwd` | `/where` | показать текущий conversation id |
|
|
||||||
| `/exit` | `/quit` | выйти (Ctrl-D / Ctrl-C — то же) |
|
|
||||||
|
|
||||||
Свободный текст = сообщение текущему диалогу; SSE-события (start_reasoning /
|
## Примеры
|
||||||
start_response / append_text / end / interrupted / error) стримятся в stdout
|
|
||||||
в реальном времени.
|
|
||||||
|
|
||||||
## Переменные окружения
|
```bash
|
||||||
|
# Список диалогов (таблица)
|
||||||
|
agentik-cli conv ls --server http://localhost:8080/agentik
|
||||||
|
|
||||||
| Переменная | Дефолт | Назначение |
|
# Создать диалог
|
||||||
|---|---|---|
|
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
|
||||||
| `AGENTIK_SERVER` | `http://localhost:8080/agentik` | URL HTTP-фасада `:server` |
|
echo "new conv: $ID"
|
||||||
|
|
||||||
История сессий (id + last event timestamp) сохраняется в
|
# Переименовать
|
||||||
`~/.agentik/cli-state.json` (атомарно через `tmp → rename`).
|
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
|
||||||
|
|
||||||
## Особенности
|
# Отправить ход и стримить ответ
|
||||||
|
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
|
||||||
|
|
||||||
- **Arrow keys, history (↑/↓), Ctrl-D/Ctrl-C** — через JLine 3.30, история
|
# Показать последние N сообщений
|
||||||
readline в `~/.agentik/.inputrc`-стиле (через JLine `DefaultHistory`).
|
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
|
||||||
- **Reconnect-safe SSE** — если сервер рестартовал, клиент подхватывает с
|
|
||||||
`lastEventAt` через `events(after)`.
|
# Прервать активный ход
|
||||||
- **Snapshot-режим** — если подключились к диалогу впервые, `/history`
|
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
|
||||||
подгружает старые сообщения через `getMessages(after)` (offline-бэкфилл).
|
|
||||||
|
# Удалить
|
||||||
|
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
|
||||||
|
|
||||||
|
# Через env-переменную
|
||||||
|
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат вывода `send`
|
||||||
|
|
||||||
|
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
|
||||||
|
пригодно для парсинга через `awk`/`jq`-обёртки:
|
||||||
|
|
||||||
|
```
|
||||||
|
event StartReasoning
|
||||||
|
event StartResponse TEXT
|
||||||
|
event AppendText \n\n
|
||||||
|
event AppendText Привет!
|
||||||
|
event End
|
||||||
|
```
|
||||||
|
|
||||||
|
Терминальные события (`End`, `Interrupted`, `Error`) тоже
|
||||||
|
печатаются; CLI выходит сразу после `End`.
|
||||||
|
|
||||||
|
## Почему kotlinx.cli (а не clikt)
|
||||||
|
|
||||||
|
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
|
||||||
|
arg-parser, который стабильно линкуется под `linux_x64` +
|
||||||
|
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
|
||||||
|
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
|
||||||
|
ломается на native linker: `duplicate symbol selfAndAncestors`
|
||||||
|
между `clikt` и `clikt-mordant` commonMain (issue
|
||||||
|
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
|
||||||
|
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
|
||||||
|
сборку на порядки и не решает проблему до конца. Поэтому clikt
|
||||||
|
отвергнут.
|
||||||
|
|
||||||
|
## Платформенные детали
|
||||||
|
|
||||||
|
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
|
||||||
|
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
|
||||||
|
convention `MainKt.main` тут не работает — K/N линкер ищет
|
||||||
|
функцию по `package.main`.
|
||||||
|
- **`platformEnv(key)`** для чтения env-переменных:
|
||||||
|
- JVM: `System.getenv(key)` через `jvmMain` actual.
|
||||||
|
- Native: `getenv(key)` из `platform.posix` через
|
||||||
|
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
|
||||||
|
требует `@OptIn(ExperimentalForeignApi::class)`).
|
||||||
|
- **Stdout / exit code** — работают на K/N через корутины.
|
||||||
|
|
||||||
|
## Готчасы kotlinx.cli
|
||||||
|
|
||||||
|
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
|
||||||
|
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
|
||||||
|
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
|
||||||
|
сделан no-op (`override fun execute() = Unit`), иначе вывод
|
||||||
|
дочерней команды дублируется выводом родителя. Дочерние команды
|
||||||
|
смотрятся через `agentik-cli conv --help`.
|
||||||
|
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
|
||||||
|
парсится как `conv [--server ...]` + позиционный аргумент `new`
|
||||||
|
на уровне родителя — и дочерняя команда не запускается.
|
||||||
|
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
|
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
|
||||||
|
покрывается руками против живого сервера.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew :agentik-cli:jvmTest
|
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
|
||||||
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
|
||||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
import org.gradle.api.artifacts.ConfigurationContainer
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -12,67 +11,68 @@ plugins {
|
|||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
|
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
|
||||||
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
|
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
|
||||||
compilerOptions {
|
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
|
||||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
|
||||||
}
|
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
|
||||||
|
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
|
||||||
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto.
|
// поэтому clikt отвергнут.
|
||||||
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only)
|
//
|
||||||
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual,
|
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
|
||||||
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь.
|
// его потребитель) тоже без iOS.
|
||||||
jvm()
|
jvm()
|
||||||
macosX64()
|
listOf(
|
||||||
macosArm64()
|
linuxX64(),
|
||||||
iosX64()
|
macosX64(),
|
||||||
iosArm64()
|
macosArm64(),
|
||||||
iosSimulatorArm64()
|
mingwX64(),
|
||||||
linuxX64()
|
)
|
||||||
linuxArm64()
|
|
||||||
mingwX64()
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
|
implementation(project(":client"))
|
||||||
|
|
||||||
|
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
|
||||||
|
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
|
||||||
|
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
|
||||||
|
// таких проблем нет.
|
||||||
|
implementation(libs.kotlinx.cli)
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.core)
|
implementation(libs.ktor.client.cio)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||||
|
// весь код, включая platformEnv, лежит в commonMain.
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
jvm {
|
jvm {
|
||||||
binaries {
|
binaries {
|
||||||
executable {
|
executable {
|
||||||
mainClass.set("pw.binom.agentik.cli.MainKt")
|
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fatjar (uberjar) ---
|
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
|
||||||
//
|
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
|
||||||
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
|
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
|
||||||
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
|
// mainClass.set(...) выше.
|
||||||
|
listOf(
|
||||||
|
linuxX64(),
|
||||||
|
macosX64(),
|
||||||
|
macosArm64(),
|
||||||
|
mingwX64(),
|
||||||
|
).forEach {
|
||||||
|
it.binaries.executable {
|
||||||
|
entryPoint = "pw.binom.agentik.cli.main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatjar — аналог :standalone.
|
||||||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
archiveBaseName.set("agentik-cli")
|
archiveBaseName.set("agentik-cli")
|
||||||
archiveClassifier.set("all")
|
archiveClassifier.set("all")
|
||||||
@@ -80,20 +80,13 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
|||||||
group = "build"
|
group = "build"
|
||||||
|
|
||||||
from(tasks.named("jvmJar"))
|
from(tasks.named("jvmJar"))
|
||||||
val cc = try {
|
from(project.configurations.getByName("jvmRuntimeClasspath"))
|
||||||
@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()
|
mergeServiceFiles()
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
manifest {
|
manifest {
|
||||||
attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt"
|
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
|
||||||
attributes["Implementation-Title"] = "agentik-cli"
|
attributes["Implementation-Title"] = "agentik-cli"
|
||||||
attributes["Implementation-Version"] = project.version.toString()
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,323 +1,87 @@
|
|||||||
package pw.binom.agentik.cli
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.cli.ArgParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.cli.ExperimentalCli
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.cli.Subcommand
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.cli.default
|
||||||
import kotlinx.coroutines.isActive
|
import pw.binom.agentik.cli.commands.ConvCommand
|
||||||
import kotlinx.coroutines.launch
|
import pw.binom.agentik.cli.commands.InfoSubcommand
|
||||||
import kotlinx.coroutines.runBlocking
|
import pw.binom.agentik.cli.commands.InterruptSubcommand
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.cli.commands.MsgsSubcommand
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.cli.commands.SendSubcommand
|
||||||
import pw.binom.agentik.proto.Conversation
|
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import pw.binom.agentik.proto.Message
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Главный класс REPL.
|
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
|
||||||
|
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
|
||||||
*
|
*
|
||||||
* Управляет:
|
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
|
||||||
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
|
* `--server`/`--id` (иначе subcommand не видит значения родителя).
|
||||||
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
|
|
||||||
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
|
|
||||||
*
|
|
||||||
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
|
|
||||||
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
|
|
||||||
* увидит [Event.Interrupted] и сам завершится.
|
|
||||||
*/
|
*/
|
||||||
class AgentikCli internal constructor(private val config: CliConfig) {
|
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
|
||||||
|
|
||||||
private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id)
|
/**
|
||||||
private val terminal: CliTerminal = CliPlatform.openTerminal(
|
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
|
||||||
historyFile = if (config.historyEnabled) stateFilePath() else null,
|
*
|
||||||
prompt = "agentik> ",
|
* ```
|
||||||
)
|
* agentik-cli <command> [args...]
|
||||||
private val sessionRepo = SessionRepository(
|
*
|
||||||
filePath = if (config.historyEnabled) stateFilePath() else null,
|
* Commands:
|
||||||
io = CliPlatform.sessionIo(),
|
* conv ls|new|show|delete|rename операции над диалогами
|
||||||
|
* msgs <id> [--limit N] показать сообщения
|
||||||
|
* send <id> <text...> отправить ход, стримит response-события в stdout
|
||||||
|
* interrupt <id> прервать текущий ход
|
||||||
|
* info показать конфиг
|
||||||
|
*
|
||||||
|
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
|
||||||
|
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
|
||||||
|
* шарит опции родителя в subcommand.
|
||||||
|
*
|
||||||
|
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
|
||||||
|
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
|
||||||
|
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
|
||||||
|
* регистрируются у него.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCli::class)
|
||||||
|
fun runAgentikCli(args: Array<String>) {
|
||||||
|
val parser = ArgParser(
|
||||||
|
programName = "agentik-cli",
|
||||||
|
// Все аргументы после имени subcommand должны передаваться
|
||||||
|
// В subcommand-парсер, а не парситься на уровне родителя.
|
||||||
|
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
|
||||||
|
// + аргумент "new" → execute родителя, без вложенной команды.
|
||||||
|
strictSubcommandOptionsOrder = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private var currentConv: Conversation? = null
|
val conv = ConvCommand()
|
||||||
private var currentTitle: String? = null
|
parser.subcommands(
|
||||||
private var lastEventAt: Instant = Instant.DISTANT_PAST
|
conv,
|
||||||
|
MsgsSubcommand(),
|
||||||
private val scope = CoroutineScope(Dispatchers.Default)
|
SendSubcommand(),
|
||||||
|
InterruptSubcommand(),
|
||||||
suspend fun run() {
|
InfoSubcommand(),
|
||||||
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(
|
parser.parse(args)
|
||||||
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printBanner()
|
/**
|
||||||
|
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
|
||||||
// Главный цикл.
|
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
|
||||||
while (scope.isActive) {
|
* свойства родителя в subcommand).
|
||||||
terminal.print(prompt())
|
*/
|
||||||
val line = terminal.readLine() ?: break // EOF → выходим
|
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
|
||||||
val trimmed = line.trim()
|
val serverUrl: String by option(
|
||||||
if (trimmed.isEmpty()) continue
|
ArgType.String, fullName = "server", shortName = "s",
|
||||||
|
description = "Base URL агента (env AGENTIK_SERVER)",
|
||||||
if (trimmed.startsWith("/")) {
|
).default(defaultServerUrl())
|
||||||
when (val r = parseSlash(trimmed.substring(1))) {
|
val agentId: String by option(
|
||||||
is ParseResult.Success -> {
|
ArgType.String, fullName = "id", shortName = "i",
|
||||||
if (handleCommand(r.command) == CommandResult.Exit) break
|
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
|
||||||
}
|
).default(defaultAgentId())
|
||||||
is ParseResult.Failure -> terminal.printSystem(r.message)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleUserMessage(trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
terminal.printSystem("до свидания.")
|
|
||||||
currentConv?.close()
|
|
||||||
terminal.close()
|
|
||||||
sessionRepo.close()
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================ banner / prompt
|
fun main(args: Array<String>) {
|
||||||
|
runAgentikCli(args)
|
||||||
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,24 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import pw.binom.agentik.client.applyAgentikDefaults
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
|
||||||
|
*
|
||||||
|
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
|
||||||
|
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
|
||||||
|
* покрываются CIO.
|
||||||
|
*
|
||||||
|
* `requestTimeout = 0` — отключение встроенного request-таймаута CIO;
|
||||||
|
* defense-in-depth против обрыва долгих SSE-idle (основная защита —
|
||||||
|
* `noSseReadTimeout` в `:client`).
|
||||||
|
*
|
||||||
|
* [token] = `null` — авторизация выключена.
|
||||||
|
*/
|
||||||
|
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
|
||||||
|
HttpClient(CIO) {
|
||||||
|
applyAgentikDefaults(token)
|
||||||
|
engine { requestTimeout = 0 }
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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,3 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
internal expect fun platformEnv(key: String): String?
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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,32 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ExperimentalCli
|
||||||
|
import kotlinx.cli.Subcommand
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Родительская группа `conv`: операции над диалогами.
|
||||||
|
*
|
||||||
|
* Сама команда `agentik-cli conv` (без подкоманды) — no-op:
|
||||||
|
* в kotlinx.cli parent.execute() вызывается ПОСЛЕ leaf.execute(),
|
||||||
|
* поэтому любая работа в execute() дублирует вывод дочерней команды.
|
||||||
|
* Для просмотра дочерних команд есть `agentik-cli conv --help`.
|
||||||
|
*
|
||||||
|
* Дочерние команды регистрируются через [subcommands] в конструкторе.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCli::class)
|
||||||
|
class ConvCommand : Subcommand("conv", "Операции над диалогами") {
|
||||||
|
init {
|
||||||
|
subcommands(
|
||||||
|
ConvLsSubcommand(),
|
||||||
|
ConvNewSubcommand(),
|
||||||
|
ConvShowSubcommand(),
|
||||||
|
ConvDeleteSubcommand(),
|
||||||
|
ConvRenameSubcommand(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ConvSubcommand(name: String, description: String) : AgentikSubcommand(name, description)
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val ok = agent.deleteConversation(id)
|
||||||
|
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов агента") {
|
||||||
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||||
|
if (convs.isEmpty()) {
|
||||||
|
println("(no conversations)")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
println("ID UPDATED-AT TITLE FLAGS")
|
||||||
|
convs.forEach { c ->
|
||||||
|
val flags = buildString {
|
||||||
|
if (c.isTemporal) append('T')
|
||||||
|
if (c.isSupportImageInput) append('I')
|
||||||
|
if (c.isSupportImageOutput) append('O')
|
||||||
|
if (isEmpty()) append('-')
|
||||||
|
}
|
||||||
|
val title = c.title ?: "(untitled)"
|
||||||
|
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
|
||||||
|
}
|
||||||
|
println("--- ${convs.size} conversation(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||||
|
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.createConversation(temp = temp)
|
||||||
|
println(conv.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val title by argument(ArgType.String, description = "Новое название")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conv.rename(title)
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
println("renamed: $id -> $title")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
println("id: ${conv.id}")
|
||||||
|
println("title: ${conv.title ?: "(untitled)"}")
|
||||||
|
println("updatedAt: ${conv.updatedAt}")
|
||||||
|
println("isTemporal: ${conv.isTemporal}")
|
||||||
|
println("isSupportImageInput: ${conv.isSupportImageInput}")
|
||||||
|
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
|
||||||
|
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
|
||||||
|
override fun execute() {
|
||||||
|
println("server: $serverUrl")
|
||||||
|
println("id: $agentId")
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
|
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conv.interrupt()
|
||||||
|
println("interrupted: $id")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.default
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
|
||||||
|
.sortedBy { it.date }
|
||||||
|
msgs.forEach { m -> println(formatMessage(m)) }
|
||||||
|
println("--- ${msgs.size} message(s)")
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(m: Message): String =
|
||||||
|
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
|
||||||
|
|
||||||
|
private fun Message.role(): String = when (this) {
|
||||||
|
is Message.UserMessage -> "[user]"
|
||||||
|
is Message.AssistantMessage -> "[assistant]"
|
||||||
|
is Message.ToolCall -> "[tool_call]"
|
||||||
|
is Message.ToolResult -> "[tool_result]"
|
||||||
|
is Message.Error -> "[error]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Message.bodyOneLine(): String = when (this) {
|
||||||
|
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||||
|
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||||
|
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
|
||||||
|
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
|
||||||
|
is Message.Error -> "code=${code ?: "?"} message=$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Content.toOneLine(): String = when (this) {
|
||||||
|
is Content.Text -> body.replace('\n', ' ').take(200)
|
||||||
|
is Content.Image -> "<image ${data.size}B $mime>"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package pw.binom.agentik.cli.commands
|
||||||
|
|
||||||
|
import kotlinx.cli.ArgType
|
||||||
|
import kotlinx.cli.vararg
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||||
|
|
||||||
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
|
val conv = agent.getConversation(id) ?: run {
|
||||||
|
println("conversation not found: $id")
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Подписываемся на поток событий ДО send: события, отправленные
|
||||||
|
// до подписки, не реплеятся (shared-flow без replay).
|
||||||
|
val eventsJob = launch {
|
||||||
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
|
// onEach печатает и терминальный event, takeWhile лишь
|
||||||
|
// завершает сбор после него.
|
||||||
|
.map { it.event }
|
||||||
|
.onEach { ev -> emit(ev) }
|
||||||
|
.takeWhile { ev -> !isTerminal(ev) }
|
||||||
|
.collect { }
|
||||||
|
}
|
||||||
|
// Даём SSE-подписке установиться, затем шлём ход.
|
||||||
|
delay(200)
|
||||||
|
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
||||||
|
eventsJob.join()
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTerminal(ev: Event): Boolean =
|
||||||
|
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
|
||||||
|
|
||||||
|
private fun emit(ev: Event) {
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartReasoning -> println("event StartReasoning")
|
||||||
|
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
|
||||||
|
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||||
|
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||||
|
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||||
|
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
||||||
|
is Event.End -> println("event End")
|
||||||
|
is Event.Interrupted -> println("event Interrupted")
|
||||||
|
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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]"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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,3 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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,8 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.toKString
|
||||||
|
import platform.posix.getenv
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
internal actual fun platformEnv(key: String): String? = getenv(key)?.toKString()
|
||||||
+79
-82
@@ -1,106 +1,103 @@
|
|||||||
# :agentik-tui — Compose-for-Mosaic TUI клиент к /agentik
|
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
|
||||||
|
|
||||||
Compose-style TUI-клиент (KMP desktop, без iOS), рендерится в ANSI-терминал
|
## Что это
|
||||||
через библиотеку [Mosaic](https://github.com/JakeWharton/mosaic) 0.18. Полная
|
|
||||||
клавиатурная навигация — никаких `:`-префиксов (как в vim).
|
|
||||||
|
|
||||||
## Сборка
|
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
|
```bash
|
||||||
./gradlew :agentik-tui:shadowJar
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
# Результат: agentik-tui/build/libs/agentik-tui-all.jar (~10 MB)
|
-jar agentik-tui-0.1.0-all.jar \
|
||||||
|
--server http://192.168.76.166:8080/agentik
|
||||||
```
|
```
|
||||||
|
|
||||||
Также доступен через Nexus (`caffeine` репо) — `pw.binom.agentik:agentik-tui:VERSION`.
|
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
|
||||||
|
native syscalls для терминала.
|
||||||
|
|
||||||
## Запуск
|
### Запуск через Gradle (dev)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# По умолчанию — http://localhost:8080/agentik
|
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
|
||||||
java --enable-native-access=ALL-UNNAMED \
|
|
||||||
-jar agentik-tui-all.jar
|
|
||||||
|
|
||||||
# К другому серверу
|
|
||||||
java --enable-native-access=ALL-UNNAMED \
|
|
||||||
-jar agentik-tui-all.jar --server http://192.168.76.166:8080/agentik
|
|
||||||
|
|
||||||
# Без восстановления последней беседы
|
|
||||||
java --enable-native-access=ALL-UNNAMED \
|
|
||||||
-jar agentik-tui-all.jar --no-history
|
|
||||||
|
|
||||||
# Конкретная беседа
|
|
||||||
java --enable-native-access=ALL-UNNAMED \
|
|
||||||
-jar agentik-tui-all.jar --id <conversation-uuid>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Альтернативно: `AGENTIK_SERVER` env-переменная.
|
## Параметры CLI
|
||||||
|
|
||||||
> **`--enable-native-access=ALL-UNNAMED`** обязателен: Mosaic использует
|
| Флаг | ENV | Что делает |
|
||||||
> нативные API терминала (`stdin`/`stdout` raw mode), что требует
|
|
||||||
> `--enable-native-access`.
|
|
||||||
|
|
||||||
## Клавиши
|
|
||||||
|
|
||||||
| Клавиша | Действие |
|
|
||||||
|---|---|
|
|
||||||
| **Tab** / **Shift-Tab** | переключение фокуса: input → history → sidebar → … |
|
|
||||||
| **Enter** | отправить набранное сообщение |
|
|
||||||
| **Backspace** / **Delete** | удалить символ |
|
|
||||||
| **←/→** / **Home/End** | курсор в input |
|
|
||||||
| **↑/↓** | scrollback (в фокусе на history) / input history (в фокусе на input) |
|
|
||||||
| **Esc** | очистить input |
|
|
||||||
| **F1** | показать/скрыть help overlay |
|
|
||||||
| **Ctrl-D** / **Ctrl-C** | прервать активный ход (повторное нажатие — выход) |
|
|
||||||
|
|
||||||
`:`-префикс команд **отключён намеренно** — для отправки команд используются
|
|
||||||
клавиши. Если нужно сделать что-то нестандартное — переключитесь на `:agentik-cli`
|
|
||||||
(REPL с slash-командами).
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | Дефолт | Назначение |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AGENTIK_SERVER` | `http://localhost:8080/agentik` | URL HTTP-фасада `:server` |
|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
||||||
|
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
||||||
|
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
||||||
|
| `--help` | — | Показывает help и выходит |
|
||||||
|
|
||||||
История сессий: `~/.agentik/tui-state.json`.
|
## Keybindings
|
||||||
|
|
||||||
## Layout
|
| Клавиша | Когда | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `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 — пока работает только вне стрима) |
|
||||||
|
|
||||||
```
|
## Переменные среды (сервера)
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ agentik · agent-id · <conv-uuid> · focus=input │ ← header
|
|
||||||
├────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ [User 14:32] │
|
|
||||||
│ привет │
|
|
||||||
│ │
|
|
||||||
│ [Bot 14:32] │
|
|
||||||
│ привет! чем помочь? │ ← history (scroll)
|
|
||||||
│ ▍ │
|
|
||||||
│ │
|
|
||||||
├────────────────────────────────────────────────────────────────┤
|
|
||||||
│ > | | │ ← input (cursor)
|
|
||||||
├────────────────────────────────────────────────────────────────┤
|
|
||||||
│ Tab focus ↑↓ scroll Enter send Esc clear F1 help ⌃D exit │ ← footer
|
|
||||||
└────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что пока работает и что нет
|
См. [`../standalone/README.md`](../standalone/README.md). TUI
|
||||||
|
получает URL сервера через `--server`, остальное настройка
|
||||||
|
агента, а не клиента.
|
||||||
|
|
||||||
✅ header + history + input + footer
|
## Известное ограничение
|
||||||
✅ focus cycling (Tab / Shift-Tab)
|
|
||||||
✅ input editing (chars, BS, Del, ←/→, Home/End, Enter)
|
|
||||||
✅ история input'а через ↑/↓ (в фокусе на input)
|
|
||||||
✅ scrollback history (в фокусе на history)
|
|
||||||
✅ F1 help overlay
|
|
||||||
✅ Ctrl-D / Ctrl-C interrupt
|
|
||||||
|
|
||||||
⌛ mouse support (термиос SGR-mouse parsing) — для v3+.
|
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
|
||||||
⌛ split-pane (history left / input right) — пока input внизу, full-width.
|
же, что для `:agentik-cli`.
|
||||||
⌛ `:agentik-cli`-slash-команды внутри TUI (history backfill / list / switch).
|
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-клиент.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```bash
|
```
|
||||||
./gradlew :agentik-tui:jvmTest
|
./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()`).
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ kotlin {
|
|||||||
linuxArm64()
|
linuxArm64()
|
||||||
mingwX64()
|
mingwX64()
|
||||||
|
|
||||||
|
// Native executables. По умолчанию Kotlin/Native для каждого target'а
|
||||||
|
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
|
||||||
|
// явно попросить binaries.executable(). entryPoint нужно задать явно:
|
||||||
|
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
|
||||||
|
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
|
||||||
|
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
|
||||||
|
//
|
||||||
|
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
|
||||||
|
// явно (а не через targets.withType), потому что targets DSL в KMP не
|
||||||
|
// поддерживает реифицированный withType<KotlinNativeTarget>().
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
|
||||||
|
it.binaries.executable {
|
||||||
|
entryPoint = "pw.binom.agentik.tui.main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
@@ -34,6 +51,11 @@ kotlin {
|
|||||||
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||||
implementation(libs.mosaic.runtime)
|
implementation(libs.mosaic.runtime)
|
||||||
implementation(libs.mosaic.tty.terminal)
|
implementation(libs.mosaic.tty.terminal)
|
||||||
|
|
||||||
|
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
|
||||||
|
// там работает stub actual через expect/actual.
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
}
|
}
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(project(":client"))
|
implementation(project(":client"))
|
||||||
@@ -41,6 +63,7 @@ kotlin {
|
|||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ package pw.binom.agentik.tui
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.layout.onPreviewKeyEvent
|
||||||
import com.jakewharton.mosaic.modifier.Modifier
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
import com.jakewharton.mosaic.ui.Box
|
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import com.jakewharton.mosaic.ui.Column
|
||||||
import com.jakewharton.mosaic.ui.Row
|
import com.jakewharton.mosaic.ui.Row
|
||||||
import com.jakewharton.mosaic.ui.Text
|
import pw.binom.agentik.tui.ui.Footer
|
||||||
import com.jakewharton.mosaic.ui.TextStyle
|
import pw.binom.agentik.tui.ui.Header
|
||||||
|
import pw.binom.agentik.tui.ui.HelpOverlay
|
||||||
|
import pw.binom.agentik.tui.ui.HistoryPanel
|
||||||
|
import pw.binom.agentik.tui.ui.InputLine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Корневая Compose-композиция TUI.
|
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
|
||||||
|
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
|
||||||
*
|
*
|
||||||
* Layout (минимальный):
|
* Layout (минимальный):
|
||||||
* ```
|
* ```
|
||||||
@@ -28,6 +29,9 @@ import com.jakewharton.mosaic.ui.TextStyle
|
|||||||
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
* └─────────────────────────────────────────────────────────┘
|
* └─────────────────────────────────────────────────────────┘
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||||
|
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun App(state: AppState) {
|
internal fun App(state: AppState) {
|
||||||
@@ -50,105 +54,8 @@ internal fun App(state: AppState) {
|
|||||||
Header(state, focusIndex)
|
Header(state, focusIndex)
|
||||||
HistoryPanel(state)
|
HistoryPanel(state)
|
||||||
InputLine(state)
|
InputLine(state)
|
||||||
Footer(state, showHelp)
|
Footer(showHelp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showHelp) HelpOverlay()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import kotlin.time.Instant
|
|||||||
* (см. samples/snake в репо Mosaic).
|
* (см. samples/snake в репо Mosaic).
|
||||||
*/
|
*/
|
||||||
internal class AppState(val config: TuiConfig) {
|
internal class AppState(val config: TuiConfig) {
|
||||||
|
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
|
||||||
|
private var backend: TuiBackend? = null
|
||||||
|
fun attachBackend(b: TuiBackend) { backend = b }
|
||||||
|
|
||||||
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||||
private val _focusIndex = MutableStateFlow(0)
|
private val _focusIndex = MutableStateFlow(0)
|
||||||
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||||
@@ -101,9 +105,28 @@ internal class AppState(val config: TuiConfig) {
|
|||||||
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||||
inputClear()
|
inputClear()
|
||||||
_streaming.value = true
|
_streaming.value = true
|
||||||
|
backend?.onUserMessage(text)
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setStreaming(v: Boolean) { _streaming.value = v }
|
||||||
|
|
||||||
|
fun setConversation(id: String, title: String?) {
|
||||||
|
_currentConversationId.value = id
|
||||||
|
_currentTitle.value = title
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_historyScroll.value = 0
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postToolCall(toolName: String, title: String?, args: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postToolResult(toolName: String, result: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
|
||||||
|
}
|
||||||
|
|
||||||
fun appendAssistant(chunk: String) {
|
fun appendAssistant(chunk: String) {
|
||||||
val list = _messages.value.toMutableList()
|
val list = _messages.value.toMutableList()
|
||||||
val last = list.lastOrNull()
|
val last = list.lastOrNull()
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package pw.binom.agentik.tui
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Точка входа TUI-клиента agentik.
|
* Точка входа TUI-клиента agentik.
|
||||||
@@ -9,14 +15,62 @@ import kotlinx.coroutines.runBlocking
|
|||||||
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Без аргументов — стартует Compose-Mosaic UI.
|
* Перед запуском UI — обязательный health-check: `GET {server}/health`.
|
||||||
|
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
|
||||||
|
* Если OK — создаём [Agent] через платформенную actual и запускаем
|
||||||
|
* [TuiApp].
|
||||||
*/
|
*/
|
||||||
fun main(args: Array<String>) = runBlocking {
|
fun main(args: Array<String>) = runBlocking {
|
||||||
val cfg = parseCliArgs(args) ?: run {
|
val cfg = parseCliArgs(args) ?: run {
|
||||||
printUsage()
|
printUsage()
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
}
|
}
|
||||||
TuiApp(cfg).run()
|
checkServer(cfg.server)
|
||||||
|
val agent = platformCreateAgent(cfg.server, cfg.id)
|
||||||
|
TuiApp(cfg, agent).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Делает синхронный GET `{baseUrl}/health`. Внутри [route(path)] на сервере
|
||||||
|
* `/health` зарегистрирован под тем же path-prefix'ом, что и сам API
|
||||||
|
* (например, baseUrl = `http://localhost:8080/agentik` → health = …/agentik/health).
|
||||||
|
*
|
||||||
|
* При любой ошибке (connect refused, timeout, не-200 ответ, не `"ok"`) —
|
||||||
|
* бросает [IllegalStateException] с понятным сообщением. [runBlocking]-обёртка
|
||||||
|
* в [main] разворачивает её в stack-trace и `exit 1`.
|
||||||
|
*/
|
||||||
|
private suspend fun checkServer(baseUrl: String) {
|
||||||
|
val healthUrl = "${baseUrl.trimEnd('/')}/health"
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 5_000
|
||||||
|
connectTimeoutMillis = 3_000
|
||||||
|
}
|
||||||
|
expectSuccess = false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val response = client.get(healthUrl)
|
||||||
|
if (response.status.value !in 200..299) {
|
||||||
|
throw IllegalStateException("сервер ответил HTTP ${response.status.value} на GET $healthUrl")
|
||||||
|
}
|
||||||
|
val body = response.bodyAsText().trim()
|
||||||
|
if (body != "ok") {
|
||||||
|
throw IllegalStateException("сервер ответил неожиданным телом на GET $healthUrl: '$body'")
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// На JVM сюда упадут java.net.ConnectException, UnknownHostException,
|
||||||
|
// io.ktor.client.network.sockets.ConnectTimeoutException и т.п.
|
||||||
|
// На нативе native stub падает раньше в platformCreateAgent, так что
|
||||||
|
// сюда мы попадём только под JVM-actual.
|
||||||
|
throw IllegalStateException(
|
||||||
|
"ошибка health-check $healthUrl: ${e::class.simpleName} — ${e.message ?: "(нет сообщения)"}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +124,12 @@ private fun parseCliArgs(args: Array<String>): TuiConfig? {
|
|||||||
*/
|
*/
|
||||||
internal expect fun platformEnv(key: String): String?
|
internal expect fun platformEnv(key: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
|
||||||
|
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
|
||||||
|
*/
|
||||||
|
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
|
||||||
|
|
||||||
private fun printUsage() {
|
private fun printUsage() {
|
||||||
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
@@ -86,11 +146,15 @@ private fun printUsage() {
|
|||||||
--no-history не сохранять состояние
|
--no-history не сохранять состояние
|
||||||
--help, -h эта справка
|
--help, -h эта справка
|
||||||
|
|
||||||
|
Переменные среды:
|
||||||
|
AGENTIK_SERVER базовый URL (эквивалент --server)
|
||||||
|
USER / USERNAME используется в id клиента по умолчанию
|
||||||
|
|
||||||
В UI:
|
В UI:
|
||||||
Tab / Shift-Tab переключить фокус между историей и вводом
|
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||||
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||||
← / → двинуть курсор в инпуте
|
← / → двинуть курсор в инпуте
|
||||||
Enter отправить сообщение
|
Enter отправить сообщение (создаст новый диалог, если их нет)
|
||||||
Ctrl-C / Ctrl-D выйти
|
Ctrl-C / Ctrl-D выйти
|
||||||
F1 показать подсказки по горячим клавишам
|
F1 показать подсказки по горячим клавишам
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
package pw.binom.agentik.tui
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import com.jakewharton.mosaic.runMosaicBlocking
|
import com.jakewharton.mosaic.runMosaicBlocking
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import kotlin.coroutines.CoroutineContext
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
|
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
|
||||||
|
* его coroutine-scope и ждёт завершения приложения.
|
||||||
*
|
*
|
||||||
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
|
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
|
||||||
* одной Compose-композиции и пользуются её [CoroutineScope].
|
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
|
||||||
*
|
|
||||||
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
|
|
||||||
* стартует на пустом [Agent]-заглушке для smoke-теста.
|
|
||||||
*/
|
*/
|
||||||
internal class TuiApp(private val config: TuiConfig) {
|
internal class TuiApp(
|
||||||
|
private val config: TuiConfig,
|
||||||
|
private val agent: Agent,
|
||||||
|
) {
|
||||||
fun run() {
|
fun run() {
|
||||||
runMosaicBlocking {
|
runMosaicBlocking {
|
||||||
val state = remember { AppState(config) }
|
val state = remember { AppState(config) }
|
||||||
|
val backend = remember { TuiBackend(state = state, agent = agent) }
|
||||||
|
LaunchedEffect(backend) {
|
||||||
|
backend.start(this)
|
||||||
|
}
|
||||||
|
state.attachBackend(backend)
|
||||||
App(state = state)
|
App(state = state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend-логика TUI: мост между [Agent] и [AppState].
|
||||||
|
*
|
||||||
|
* Жизненный цикл:
|
||||||
|
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
|
||||||
|
* пост-сообщение "connected to …".
|
||||||
|
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
|
||||||
|
* 3. При [onUserMessage] — если текущего диалога нет, создаём
|
||||||
|
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
|
||||||
|
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
|
||||||
|
*
|
||||||
|
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
|
||||||
|
*/
|
||||||
|
internal class TuiBackend(
|
||||||
|
private val state: AppState,
|
||||||
|
private val agent: Agent,
|
||||||
|
) {
|
||||||
|
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
|
||||||
|
private var current: Conversation? = null
|
||||||
|
|
||||||
|
/** Активная джоба подписки на [Conversation.events]. */
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
|
||||||
|
/** Последний виденный момент событий — для переподписки при reconnect. */
|
||||||
|
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
|
||||||
|
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
|
||||||
|
*/
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
this.scope = scope
|
||||||
|
state.postSystem("подключено к ${state.config.server}")
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||||
|
// распаковываем .event для получения AgentEvent (раньше был
|
||||||
|
// отдельный метод agent.events(), теперь упразднён — события
|
||||||
|
// живут в outbox-сущности).
|
||||||
|
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
// штатная отмена при закрытии UI
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка пользовательского сообщения, отправленного из input.
|
||||||
|
*
|
||||||
|
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
|
||||||
|
* события конкретного диалога стартует в [ensureConversation].
|
||||||
|
*/
|
||||||
|
fun onUserMessage(text: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val conv = ensureConversation()
|
||||||
|
conv.send(listOf(Content.Text(text)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
|
||||||
|
state.setStreaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
|
||||||
|
*/
|
||||||
|
private suspend fun ensureConversation(): Conversation {
|
||||||
|
current?.let { return it }
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
state.setConversation(id = conv.id, title = conv.title)
|
||||||
|
subscribeEvents(conv, Instant.DISTANT_PAST)
|
||||||
|
current = conv
|
||||||
|
return conv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
||||||
|
*/
|
||||||
|
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг [Event] → [AppState] (что показать в TUI).
|
||||||
|
*
|
||||||
|
* - AppendText → дописывает в последний ассистентский чанк
|
||||||
|
* - StartReasoning / StartResponse → новый streaming-чанк
|
||||||
|
* - End → закрывает streaming
|
||||||
|
* - Interrupted → закрывает streaming + системное сообщение
|
||||||
|
* - ToolCall / ToolResult → сообщения в историю
|
||||||
|
* - Error → системное сообщение
|
||||||
|
*/
|
||||||
|
private fun dispatch(ev: Event) {
|
||||||
|
lastSeenAt = ev.date
|
||||||
|
when (ev) {
|
||||||
|
is Event.AppendText -> state.appendAssistant(ev.body)
|
||||||
|
is Event.StartReasoning -> {
|
||||||
|
state.postSystem("… думаю")
|
||||||
|
}
|
||||||
|
is Event.StartResponse -> state.setStreaming(true)
|
||||||
|
is Event.End -> state.finishAssistant()
|
||||||
|
is Event.Interrupted -> {
|
||||||
|
state.finishAssistant()
|
||||||
|
state.postSystem("прервано")
|
||||||
|
}
|
||||||
|
is Event.AppendImage -> {
|
||||||
|
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
||||||
|
}
|
||||||
|
is Event.ToolCall -> {
|
||||||
|
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
||||||
|
}
|
||||||
|
is Event.ToolResult -> {
|
||||||
|
state.postToolResult(toolName = "", result = ev.result ?: "")
|
||||||
|
}
|
||||||
|
is Event.Error -> {
|
||||||
|
state.setStreaming(false)
|
||||||
|
state.postSystem("ошибка: ${ev.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя подсказка с текущим набором горячих клавиш.
|
||||||
|
*
|
||||||
|
* При открытом help-оверлее показывает заглушку с указателем «наверху».
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Footer(showHelp: Boolean) {
|
||||||
|
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||||
|
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||||
|
Text(value = hint, textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
|
||||||
|
*
|
||||||
|
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Header(state: AppState, focusIndex: Int) {
|
||||||
|
val title by state.currentTitle.collectAsState()
|
||||||
|
val convId by state.currentConversationId.collectAsState()
|
||||||
|
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||||
|
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||||
|
val titleStr = title ?: "(нет диалога)"
|
||||||
|
Text(
|
||||||
|
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||||
|
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полноэкранный оверлей со списком горячих клавиш.
|
||||||
|
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HelpOverlay() {
|
||||||
|
Column(modifier = Modifier) {
|
||||||
|
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||||
|
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||||
|
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||||
|
Text(value = " ← / → курсор в input")
|
||||||
|
Text(value = " Enter отправить сообщение")
|
||||||
|
Text(value = " Backspace / Del удалить символ")
|
||||||
|
Text(value = " Esc очистить input")
|
||||||
|
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||||
|
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
import pw.binom.agentik.tui.TuiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прокручиваемый (через клавиатуру) лог диалога.
|
||||||
|
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
|
||||||
|
* При пустом списке показывается подсказка.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryPanel(state: AppState) {
|
||||||
|
val messages by state.messages.collectAsState()
|
||||||
|
val rendered = if (messages.isEmpty()) {
|
||||||
|
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||||
|
} else {
|
||||||
|
messages.joinToString("") { renderMessage(it) }
|
||||||
|
}
|
||||||
|
Text(value = rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
|
||||||
|
internal fun renderMessage(m: TuiMessage): String = when (m) {
|
||||||
|
is TuiMessage.System -> " ── ${m.text}\n"
|
||||||
|
is TuiMessage.User -> " > ${m.text}\n"
|
||||||
|
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||||
|
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||||
|
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||||
|
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.layout.KeyEvent
|
||||||
|
import com.jakewharton.mosaic.layout.drawBehind
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя строка ввода с курсором.
|
||||||
|
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
|
||||||
|
*
|
||||||
|
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
|
||||||
|
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
|
||||||
|
*
|
||||||
|
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun InputLine(state: AppState) {
|
||||||
|
val text by state.input.collectAsState()
|
||||||
|
val cursor by state.cursor.collectAsState()
|
||||||
|
val streaming by state.streaming.collectAsState()
|
||||||
|
val cursorPos = cursor.coerceIn(0, text.length)
|
||||||
|
val before = text.substring(0, cursorPos)
|
||||||
|
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||||
|
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||||
|
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||||
|
val prompt = if (streaming) " ⋯" else " >"
|
||||||
|
|
||||||
|
Text(
|
||||||
|
value = "$prompt $before|$cursorChar|${after}",
|
||||||
|
modifier = Modifier
|
||||||
|
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||||
|
.drawBehind {
|
||||||
|
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||||
|
state.input.let { /* touch */ }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
|
||||||
|
*
|
||||||
|
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
|
||||||
|
* на корневой обработчик ([pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||||
|
if (ev.alt || ev.ctrl) return false
|
||||||
|
return when (ev.key) {
|
||||||
|
"Enter" -> state.submitInput() != null
|
||||||
|
"Backspace" -> { state.inputBackspace(); true }
|
||||||
|
"Delete" -> { state.inputDelete(); true }
|
||||||
|
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||||
|
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||||
|
"Home" -> { state.inputCursorHome(); true }
|
||||||
|
"End" -> { state.inputCursorEnd(); true }
|
||||||
|
else -> {
|
||||||
|
val s = ev.key
|
||||||
|
if (s.length == 1) { state.inputInsert(s); true }
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.proto.Message
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
|
||||||
|
* вызвали [createConversation], и отдаёт заранее сконструированные
|
||||||
|
* [FakeConversation].
|
||||||
|
*/
|
||||||
|
internal class FakeAgent(
|
||||||
|
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
||||||
|
) : Agent {
|
||||||
|
override val id: String = "fake"
|
||||||
|
var createCount: Int = 0
|
||||||
|
private set
|
||||||
|
val conversations = mutableListOf<FakeConversation>()
|
||||||
|
|
||||||
|
// Storage handles не используются тестами TuiBackend — тесты проверяют
|
||||||
|
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
|
||||||
|
// emptyFlow, journal — error-on-access (никто не должен его трогать).
|
||||||
|
override val journal: JournalStore = error("journal not used in TuiBackend tests")
|
||||||
|
override val outbox: OutboxStore = object : OutboxStore {
|
||||||
|
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||||
|
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
|
||||||
|
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
|
||||||
|
|
||||||
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
|
createCount++
|
||||||
|
val c = conversationFactory()
|
||||||
|
conversations += c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getConversation(id: String): Conversation? =
|
||||||
|
conversations.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String): Boolean =
|
||||||
|
conversations.removeAll { it.id == id }
|
||||||
|
|
||||||
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
|
||||||
|
* [Event] через общий [MutableSharedFlow]. Используется в тестах
|
||||||
|
* [TuiBackend] для проверки маршрутизации событий в UI.
|
||||||
|
*/
|
||||||
|
internal class FakeConversation(
|
||||||
|
override val id: String = "fake-conv",
|
||||||
|
override val title: String? = null,
|
||||||
|
) : Conversation {
|
||||||
|
override val isSupportImageInput: Boolean = false
|
||||||
|
override val isSupportImageOutput: Boolean = false
|
||||||
|
override val isTemporal: Boolean = false
|
||||||
|
override val updatedAt: Instant = Instant.DISTANT_PAST
|
||||||
|
|
||||||
|
val sent = mutableListOf<List<Content>>()
|
||||||
|
val sentContexts = mutableListOf<MessageContext?>()
|
||||||
|
var closed: Boolean = false
|
||||||
|
private set
|
||||||
|
var interrupted: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
|
||||||
|
fun emit(e: Event) { eventsFlow.tryEmit(e) }
|
||||||
|
|
||||||
|
override suspend fun rename(title: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
|
sent += content
|
||||||
|
sentContexts += context
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() { interrupted = true }
|
||||||
|
|
||||||
|
override fun events(after: Instant): Flow<Event> = eventsFlow
|
||||||
|
|
||||||
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||||
|
|
||||||
|
override fun close() { closed = true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
|
||||||
|
* для передачи в `start` — фоновые подписки должны жить параллельно с
|
||||||
|
* телом теста и автоматически отменяться по его завершении. Иначе
|
||||||
|
* бесконечный collect на `agent.events()` завешивает runTest на 60s
|
||||||
|
* `UncompletedCoroutinesError`.
|
||||||
|
*
|
||||||
|
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
|
||||||
|
* потому что `backgroundScope` использует свой диспетчер, который не
|
||||||
|
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
|
||||||
|
* те задачи, что готовы к запуску сейчас.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class TuiBackendTest {
|
||||||
|
|
||||||
|
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
|
||||||
|
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start posts connected system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains(cfg.server) },
|
||||||
|
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("привет")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(1, agent.createCount, "должен быть один createConversation")
|
||||||
|
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
|
||||||
|
// temp=false — обычный (не временный) диалог: персистится на сервере
|
||||||
|
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
|
||||||
|
// state знает id и title нового диалога
|
||||||
|
assertEquals("fake-conv", state.currentConversationId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `second onUserMessage reuses same conversation`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent()
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("раз")
|
||||||
|
runCurrent()
|
||||||
|
backend.onUserMessage("два")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
|
||||||
|
assertEquals(2, agent.conversations.first().sent.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AppendText appends to current assistant streaming chunk`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "Привет"))
|
||||||
|
conv.emit(Event.AppendText(now, ", мир"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
|
||||||
|
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
|
||||||
|
assertEquals("Привет, мир", assistantMsgs.single().text)
|
||||||
|
assertTrue(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `End event finalizes assistant and stops streaming`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "ответ"))
|
||||||
|
conv.emit(Event.End(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val last = state.messages.value.last()
|
||||||
|
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
|
||||||
|
assertEquals("ответ", (last as TuiMessage.Assistant).text)
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Interrupted event clears streaming and posts system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.AppendText(now, "часть ответа"))
|
||||||
|
conv.emit(Event.Interrupted(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains("прервано") },
|
||||||
|
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||||
|
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||||
|
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
|
||||||
|
assertEquals(1, toolMsgs.size)
|
||||||
|
assertEquals("echo", toolMsgs.single().toolName)
|
||||||
|
assertEquals("""{"x":1}""", toolMsgs.single().args)
|
||||||
|
assertEquals(1, resultMsgs.size)
|
||||||
|
assertEquals("ok", resultMsgs.single().result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Error event posts system message and clears streaming`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||||
|
conv.emit(Event.Error(date = now, message = "boom"))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
|
||||||
|
assertFalse(state.streaming.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(
|
||||||
|
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
|
||||||
|
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
|
||||||
|
)
|
||||||
|
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `StartReasoning posts thinking system message`() = runTest {
|
||||||
|
val cfg = fixtureConfig()
|
||||||
|
val state = AppState(cfg)
|
||||||
|
val conv = FakeConversation()
|
||||||
|
val agent = FakeAgent(conversationFactory = { conv })
|
||||||
|
val backend = TuiBackend(state = state, agent = agent)
|
||||||
|
backend.start(backgroundScope)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
backend.onUserMessage("hi")
|
||||||
|
runCurrent()
|
||||||
|
val now = kotlin.time.Clock.System.now()
|
||||||
|
conv.emit(Event.StartReasoning(now))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||||
|
assertTrue(sysMsgs.any { it.text.contains("думаю") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<List<Content>>.flattenText(): List<String> =
|
||||||
|
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
|
||||||
@@ -4,11 +4,9 @@ import pw.binom.agentik.client.AgentikAgent
|
|||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки.
|
* Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
|
||||||
*/
|
*/
|
||||||
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
/**
|
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
|
AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
*/
|
|
||||||
internal fun jvmCreateAgent(baseUrl: String, id: String): Agent = AgentikAgent(id = id, baseUrl = baseUrl)
|
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ import pw.binom.agentik.proto.Agent
|
|||||||
*/
|
*/
|
||||||
internal actual fun platformEnv(key: String): String? = null
|
internal actual fun platformEnv(key: String): String? = null
|
||||||
|
|
||||||
internal fun nativeCreateAgent(baseUrl: String, id: String): Agent =
|
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
|
|||||||
+8
-6
@@ -11,14 +11,16 @@ group = "pw.binom.agentik"
|
|||||||
// прочитать через rootProject.extra["projectVersion"].
|
// прочитать через rootProject.extra["projectVersion"].
|
||||||
|
|
||||||
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
||||||
// fallback в gradle.properties ("version=0.1.0"). Без версии maven-publish падает
|
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
|
||||||
// с "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
|
||||||
|
// gradle.properties). Без версии maven-publish падает с
|
||||||
|
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
||||||
// это известный gotcha: subprojects читают rootProject.version ДО того, как
|
// это известный gotcha: subprojects читают rootProject.version ДО того, как
|
||||||
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
|
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
|
||||||
// eagerly, и subprojects получают готовую строку.
|
// eagerly, и subprojects получают готовую строку.
|
||||||
val projectVersion: String = providers.gradleProperty("version")
|
val projectVersion: String = providers.gradleProperty("version")
|
||||||
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
|
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
|
||||||
.getOrElse("0.1.0")
|
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
|
||||||
version = projectVersion
|
version = projectVersion
|
||||||
extra["projectVersion"] = projectVersion
|
extra["projectVersion"] = projectVersion
|
||||||
|
|
||||||
@@ -45,11 +47,11 @@ val moduleDescriptions: Map<String, String> = mapOf(
|
|||||||
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||||
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||||
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||||
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
|
||||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
"storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
|
||||||
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
"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) с клавиатурной навигацией без ':'-префиксов.",
|
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||||
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
||||||
)
|
)
|
||||||
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||||
|
|||||||
+502
-50
@@ -1,67 +1,519 @@
|
|||||||
# :client — `pw.binom.agentik.client`
|
# `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
|
||||||
|
|
||||||
**Ktor-клиент, превращающий HTTP-фасад `:server` обратно в `Agent`/`Conversation` из `:proto`.**
|
Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
|
||||||
Подходит для JVM-приложений (CLI, desktop, integration-тесты).
|
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
|
||||||
|
про HTTP, JSON, SSE и lifecycle `Conversation`.
|
||||||
|
|
||||||
## Какую проблему решает
|
## Что есть
|
||||||
|
|
||||||
После того, как `:server` выставляет агента по HTTP, встаёт задача: дать
|
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
|
||||||
вызывающей стороне **тот же интерфейс**, что был на сервере — а не отдельный
|
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
|
||||||
REST-клиент с хендшейкингом SSE, парсингом полей, ручной постраничной подгрузкой.
|
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
|
||||||
`:client` — обёртка: `AgentikAgent(baseUrl).createConversation()` возвращает
|
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||||
`Conversation`, идентичный серверному, а вызовы `send/getMessages/events`
|
`deleteConversation` / `journal` / `outbox` / `close`.
|
||||||
прозрачно ездят по HTTP.
|
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
|
||||||
|
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
|
||||||
|
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||||
|
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||||
|
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||||
|
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
|
||||||
|
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
|
||||||
|
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
|
||||||
|
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
|
||||||
|
с основным потоком событий. См. ниже.
|
||||||
|
|
||||||
## Использование
|
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||||
|
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||||
```kotlin
|
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
|
||||||
|
|
||||||
val agent = AgentikAgent(id = "ops-bot", baseUrl = "http://localhost:8080/agentik")
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
conv.events(after = Clock.System.now()).collect { ev ->
|
|
||||||
when (ev) {
|
|
||||||
is Event.AppendText -> print(ev.body)
|
|
||||||
is Event.End -> println()
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conv.send(listOf(Content.Text("Привет. Сколько будет 2+2?")))
|
|
||||||
// ... события стримятся в collect выше
|
|
||||||
conv.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
Для фоновой подписки (reconnect-safe):
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// Долгая живая подписка на события диалога.
|
|
||||||
conv.events(after = lastSeen).collect { ev ->
|
|
||||||
if (ev is Event.End) lastSeen = ev.date
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Подключение
|
## Подключение
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
implementation("pw.binom.agentik:client:$version")
|
// build.gradle.kts
|
||||||
// Транзитивно тянет :proto + ktor-client-core/cio/... + kotlinx-serialization.
|
dependencies {
|
||||||
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
|
// Движок — на твой выбор (один из):
|
||||||
|
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
|
||||||
|
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
|
||||||
|
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
|
||||||
|
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||||
|
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||||
|
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## SSE-парсер
|
## Что клиент хранит локально (persistence)
|
||||||
|
|
||||||
Внутри — самописный парсер SSE (режет поток на `data:` строки, буферизует
|
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
|
||||||
частичные, переживает keep-alive-комментарии). Зависимости — `ktor-client-cio`
|
|
||||||
по умолчанию; если нужен другой engine — подмените через `AgentikAgent(engineFactory = …)`.
|
|
||||||
|
|
||||||
## Где смотреть версии
|
| Поле | Что это | Где взять |
|
||||||
|
|---|---|---|
|
||||||
|
| `clientId` (параметр `id` в `AgentikAgent`) | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это **произвольная строка клиента**, обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется один раз при первом запуске (`UUID.randomUUID().toString()`) и сохраняется. Никогда не меняется. |
|
||||||
|
| `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
|
||||||
|
| `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
|
||||||
|
|
||||||
- `:client` синхронизирован с `:proto`/`server` — `version` из `gradle.properties`
|
Опционально (для UX): `engineFactory` — обычно compile-time выбор по платформе (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS).
|
||||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
|
||||||
|
|
||||||
## Сборка
|
Минимальный JSON для UI, который хранит в файле:
|
||||||
|
|
||||||
```bash
|
```json
|
||||||
./gradlew :client:build
|
{
|
||||||
|
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"baseUrl": "https://agent.example.com/agentik",
|
||||||
|
"token": "s3cret"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
JVM-only (ktor-client-engine-cio — JVM).
|
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
|
||||||
|
|
||||||
|
## Быстрый старт: свой клиент за 5 минут
|
||||||
|
|
||||||
|
Один self-contained пример: создаём агента, открываем диалог,
|
||||||
|
отправляем сообщение, печатаем streaming-ответ.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "my-client",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
token = "s3cret", // или null, если не нужен
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Открыть диалог, отправить сообщение.
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет")))
|
||||||
|
|
||||||
|
// 3. Собирать streaming-ответ.
|
||||||
|
conv.events(after = Clock.System.now()).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartResponse -> println("[start]")
|
||||||
|
is Event.AppendText -> print(ev.body)
|
||||||
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Чистый shutdown.
|
||||||
|
conv.close()
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||||
|
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||||
|
|
||||||
|
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||||
|
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||||
|
|
||||||
|
### Добавить локальный кэш истории (ещё 4 строки)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Backfill + live-refresh в одном фоне:
|
||||||
|
launch {
|
||||||
|
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||||
|
.collect { cache.append(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// История — теперь из кэша, без HTTP:
|
||||||
|
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
history.forEach { rec ->
|
||||||
|
when (rec) {
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон "remote.listFlow → local.append" работает с любым
|
||||||
|
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||||
|
"без геморроя".
|
||||||
|
|
||||||
|
### Что вообще не нужно писать самому
|
||||||
|
|
||||||
|
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
|
||||||
|
`agentikJson` и `InstantSerializer`.
|
||||||
|
- SSE-парсер — `readSse()` внутри `:client`.
|
||||||
|
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||||
|
`JournalStore.listFlow` сама пагинирует.
|
||||||
|
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||||
|
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
|
||||||
|
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
|
||||||
|
- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }`
|
||||||
|
создаётся здесь; для нестандартных движковых настроек используй
|
||||||
|
`agentikHttpClient(engineFactory, token)` напрямую (он экспортирован).
|
||||||
|
|
||||||
|
### Что нужно написать самому
|
||||||
|
|
||||||
|
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||||
|
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||||
|
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||||
|
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||||
|
|
||||||
|
|
||||||
|
## Базовый пример: send + collect events
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "agentik",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
)
|
||||||
|
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||||
|
|
||||||
|
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.AppendText -> print(ev.body) // streaming чанки
|
||||||
|
is Event.End -> println("\n--- end ---")
|
||||||
|
is Event.Error -> error("agent error: ${ev.message}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## История с локальным кэшем
|
||||||
|
|
||||||
|
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||||
|
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||||
|
чтение истории — из локального кэша.
|
||||||
|
|
||||||
|
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||||
|
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||||
|
реализовывал интерфейс.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ChatSession(
|
||||||
|
private val agent: pw.binom.agentik.proto.Agent,
|
||||||
|
val conversationId: String,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
|
||||||
|
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
|
||||||
|
// (модуль `:journal-api`).
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
|
||||||
|
private val scope = kotlinx.coroutines.CoroutineScope(
|
||||||
|
kotlinx.coroutines.SupervisorJob() +
|
||||||
|
kotlinx.coroutines.Dispatchers.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||||
|
scope.launch {
|
||||||
|
agent.journal.listFlow(
|
||||||
|
conversationId = conversationId,
|
||||||
|
after = Instant.DISTANT_PAST,
|
||||||
|
).collect { cache.append(it) }
|
||||||
|
}
|
||||||
|
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||||
|
scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
if (ev is Event.End) {
|
||||||
|
val newest = cache.let {
|
||||||
|
// last-seen курсор — последний createdAt в кэше
|
||||||
|
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||||
|
?: Instant.DISTANT_PAST
|
||||||
|
}
|
||||||
|
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
|
||||||
|
.forEach { cache.append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun history() = kotlinx.coroutines.runBlocking {
|
||||||
|
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование:
|
||||||
|
val session = ChatSession(agent, conv.id)
|
||||||
|
|
||||||
|
// История — из кэша:
|
||||||
|
session.history().forEach { rec ->
|
||||||
|
when (rec) {
|
||||||
|
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
|
||||||
|
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
|
||||||
|
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
|
||||||
|
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
|
||||||
|
is MessageRecord.Error -> println("error: ${rec.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправить новое сообщение:
|
||||||
|
session.scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||||
|
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||||
|
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||||
|
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||||
|
|
||||||
|
## Кэш списка бесед
|
||||||
|
|
||||||
|
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
|
||||||
|
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
|
||||||
|
updatedAt, без `Conversation` handle и без флагов image-support).
|
||||||
|
|
||||||
|
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
|
||||||
|
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
|
||||||
|
моментально реагировал на создание/удаление/переименование в другой
|
||||||
|
вкладке.
|
||||||
|
|
||||||
|
Подход — тот же **«remote → local snapshot + live-events»**:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
|
||||||
|
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
|
||||||
|
// remote.listFlow → local.upsert (snapshot)
|
||||||
|
// outbox.agentEvents → local.upsert / delete (live)
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "agentik",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кэш уже наполняется в фоне, читать можно сразу:
|
||||||
|
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
|
||||||
|
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
|
||||||
|
|
||||||
|
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
|
||||||
|
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is AgentEvent.Created -> println("+ ${ev.conversationId}")
|
||||||
|
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
|
||||||
|
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
|
||||||
|
is AgentEvent.Deleted -> println("- ${ev.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
|
||||||
|
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
|
||||||
|
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
|
||||||
|
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
|
||||||
|
если нужен SQLite/JSON-store.
|
||||||
|
|
||||||
|
## Стриминг live-ответа
|
||||||
|
|
||||||
|
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||||
|
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||||
|
только для UI-feedback во время хода. После `End` хода запись уже
|
||||||
|
появится в кэше через refresh-блок выше.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
|
||||||
|
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartResponse -> println("[start]")
|
||||||
|
is Event.AppendText -> print(ev.body)
|
||||||
|
is Event.AppendImage -> showImage(ev.body)
|
||||||
|
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
||||||
|
is Event.ToolResult -> println("[result]")
|
||||||
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Прерывание хода
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
agent.getConversation(convId)!!.interrupt()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-conversation
|
||||||
|
|
||||||
|
Один `Agent`, много `ChatSession`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val sessions = mutableMapOf<String, ChatSession>()
|
||||||
|
|
||||||
|
fun open(convId: String): ChatSession =
|
||||||
|
sessions.getOrPut(convId) { ChatSession(agent, convId) }
|
||||||
|
|
||||||
|
fun close(convId: String) {
|
||||||
|
sessions.remove(convId)?.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
|
||||||
|
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
|
||||||
|
|
||||||
|
## Где `:client` НЕ помогает
|
||||||
|
|
||||||
|
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||||
|
отдаёт типы и потоки.
|
||||||
|
- **Персистентность кэша** — `InMemoryJournalStore` и
|
||||||
|
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
|
||||||
|
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
|
||||||
|
в `:journal-ksqlite` как образец).
|
||||||
|
- **Нестандартные движковые настройки** — для `requestTimeout`,
|
||||||
|
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
|
||||||
|
напрямую.
|
||||||
|
|
||||||
|
## Кэш списка бесед
|
||||||
|
|
||||||
|
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
|
||||||
|
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
|
||||||
|
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
|
||||||
|
|
||||||
|
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
|
||||||
|
в `localStore.upsert(...)`.
|
||||||
|
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
|
||||||
|
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
|
||||||
|
- `Deleted(id)` → `local.delete(id)`
|
||||||
|
- `Renamed(id, title)` → `local.rename(id, title)`
|
||||||
|
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
|
||||||
|
|
||||||
|
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
|
||||||
|
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
|
||||||
|
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
|
||||||
|
клиент видит их через SSE и применяет к локальной копии.
|
||||||
|
|
||||||
|
**Команды** (создать / переименовать / удалить) идут через `agent`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Создать новую беседу:
|
||||||
|
val conv = agent.createConversation(temp = false) // → POST /conversations
|
||||||
|
// → server эмитит Created
|
||||||
|
// → client cache получает Created
|
||||||
|
// → UI увидит её в списке
|
||||||
|
// Переименовать:
|
||||||
|
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
|
||||||
|
// → server эмитит Renamed
|
||||||
|
// → client cache обновляет title
|
||||||
|
// Удалить:
|
||||||
|
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
|
||||||
|
// → server эмитит Deleted
|
||||||
|
// → client cache удаляет запись
|
||||||
|
```
|
||||||
|
|
||||||
|
`conversationStore` доступен **только для чтения**. Это read-only projection
|
||||||
|
на серверную таблицу `conversation` (id + title + timestamps). Для активной
|
||||||
|
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
|
||||||
|
|
||||||
|
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
|
||||||
|
командами `agent.createConversation / deleteConversation / renameConversation`.
|
||||||
|
|
||||||
|
### Если хочется своего cache-импла
|
||||||
|
|
||||||
|
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
|
||||||
|
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
|
||||||
|
после перезапуска) — реализуй свой `MutableConversationStore` поверх
|
||||||
|
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
|
||||||
|
`:journal-ksqlite` как образец.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
|
||||||
|
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
|
||||||
|
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
|
||||||
|
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
|
||||||
|
override suspend fun delete(id: String): Boolean { /* DELETE */ }
|
||||||
|
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
|
||||||
|
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew :client:jvmTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||||
|
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
|
||||||
|
reconnect / exhausted attempts → Failed / close → cancel).
|
||||||
|
|
||||||
|
## Auto-reconnect для живого outbox
|
||||||
|
|
||||||
|
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
|
||||||
|
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
|
||||||
|
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = agent.outbox, // или HttpEventStore
|
||||||
|
scope = myScreenScope,
|
||||||
|
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
|
||||||
|
)
|
||||||
|
|
||||||
|
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
|
||||||
|
scope.launch {
|
||||||
|
recon.connectionStatus().collect { status ->
|
||||||
|
when (status) {
|
||||||
|
is Connecting -> ui.showBanner("connecting...")
|
||||||
|
is Connected -> ui.hideBanner()
|
||||||
|
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
|
||||||
|
is Failed -> ui.showError(status.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На выходе (например, navigation back):
|
||||||
|
recon.close() // отменяет background-loop, потоки терминируются
|
||||||
|
```
|
||||||
|
|
||||||
|
Два потока **независимы** — `events()` содержит только `CommonEvent`,
|
||||||
|
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
|
||||||
|
"мешающего" `Connecting`/`Disconnected` в потоке событий.
|
||||||
|
|
||||||
|
Параметры backoff (см. `BackoffPolicy`):
|
||||||
|
- `initial` / `max` — границы задержки
|
||||||
|
- `multiplier` — множитель на каждом шаге
|
||||||
|
- `jitter` — рандом-разброс (по умолчанию 20%)
|
||||||
|
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
|
||||||
|
|
||||||
|
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
|
||||||
|
|
||||||
|
## Известное ограничение
|
||||||
|
|
||||||
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
|
|||||||
+39
-11
@@ -1,21 +1,31 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
jvmToolchain(21)
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
|
||||||
implementation(project(":proto"))
|
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
|
||||||
|
// без iOS. См. agentik-cli/build.gradle.kts.
|
||||||
|
jvm()
|
||||||
|
listOf(
|
||||||
|
macosX64(),
|
||||||
|
macosArm64(),
|
||||||
|
linuxX64(),
|
||||||
|
linuxArm64(),
|
||||||
|
mingwX64(),
|
||||||
|
)
|
||||||
|
|
||||||
implementation(libs.ktor.client.core)
|
sourceSets {
|
||||||
implementation(libs.ktor.client.cio)
|
commonMain.dependencies {
|
||||||
|
api(project(":proto"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
implementation(project(":journal-inmemory"))
|
||||||
|
|
||||||
|
api(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
@@ -23,3 +33,21 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.core)
|
implementation(libs.kotlinx.serialization.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.test.host)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.sse)
|
||||||
|
}
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// :client — это библиотека, не executable. Native-бинари объявляются
|
||||||
|
// в :agentik-cli (он зависит от :client и реально предоставляет main).
|
||||||
|
}
|
||||||
|
|||||||
+25
-26
@@ -5,37 +5,41 @@ import io.ktor.client.call.body
|
|||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
*
|
*
|
||||||
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||||
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
*
|
||||||
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
|
||||||
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
* views на серверные хранилища. Запись — только через команды
|
||||||
* решает, что делать.
|
* [createConversation] / [deleteConversation] / [renameConversation].
|
||||||
*/
|
*/
|
||||||
internal class AgentClient(
|
internal class AgentClient(
|
||||||
private val httpClient: HttpClient,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: HttpClient,
|
||||||
) : Agent {
|
) : Agent {
|
||||||
|
|
||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
|
||||||
override fun createConversation(temp: Boolean): Conversation =
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
@@ -57,22 +61,17 @@ internal class AgentClient(
|
|||||||
return response.status == HttpStatusCode.NoContent
|
return response.status == HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
|
override suspend fun renameConversation(id: String, title: String?): Instant? {
|
||||||
val snapshots = httpClient.get("$agentUrl/conversations") {
|
val response = httpClient.patch("$agentUrl/conversations/$id") {
|
||||||
parameter("offset", offset)
|
contentType(ContentType.Application.Json)
|
||||||
parameter("limit", limit)
|
setBody(RequestRename(title))
|
||||||
}.body<List<ConversationSnapshot>>()
|
}
|
||||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
|
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
return rec.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
override fun close() {
|
||||||
val response = httpClient.get("$agentUrl/events?after=$after")
|
httpClient.close()
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||||
|
*
|
||||||
|
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
|
||||||
|
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
|
||||||
|
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||||
|
* снаружи — всё под капотом.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val agent = AgentikAgent(
|
||||||
|
* id = "my-client",
|
||||||
|
* baseUrl = "http://localhost:8080/agentik",
|
||||||
|
* engineFactory = CIO,
|
||||||
|
* token = "s3cret",
|
||||||
|
* )
|
||||||
|
* val conv = agent.createConversation(temp = false)
|
||||||
|
* conv.send(listOf(Content.Text("hi")))
|
||||||
|
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
|
* .map { it.event }
|
||||||
|
* .collect { ... }
|
||||||
|
* agent.close() // закрывает HttpClient + локальный кэш
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Что клиент должен хранить локально (persistence)
|
||||||
|
*
|
||||||
|
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
|
||||||
|
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
|
||||||
|
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
|
||||||
|
* не навязывает формат, но вот минимальный набор, который клиент должен
|
||||||
|
* сериализовать у себя, чтобы пережить перезапуск:
|
||||||
|
*
|
||||||
|
* | Поле | Что это | Где взять |
|
||||||
|
* |---|---|---|
|
||||||
|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
|
||||||
|
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
|
||||||
|
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
|
||||||
|
*
|
||||||
|
* Опционально (для UX):
|
||||||
|
* | Поле | Зачем |
|
||||||
|
* |---|---|
|
||||||
|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
|
||||||
|
*
|
||||||
|
* Пример минимального persistence-файла (для UI, который хранит JSON):
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "baseUrl": "https://agent.example.com/agentik",
|
||||||
|
* "token": "s3cret"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
|
||||||
|
* и больше не меняется — иначе сломается log multiplexing на сервере.
|
||||||
|
*
|
||||||
|
* ## Локальный кэш списка бесед
|
||||||
|
*
|
||||||
|
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
|
||||||
|
* Внутри лежит [InMemoryMutableConversationStore], который:
|
||||||
|
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
|
||||||
|
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
|
||||||
|
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
|
||||||
|
* соответствующий `upsert/delete/rename/touch` к локальной копии.
|
||||||
|
*
|
||||||
|
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
|
||||||
|
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
|
||||||
|
* через [Agent] и **не** через `conversationStore` (он read-only).
|
||||||
|
*
|
||||||
|
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||||
|
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
|
||||||
|
* После этого `createConversation` / `getConversation` etc. не определены.
|
||||||
|
*/
|
||||||
|
fun AgentikAgent(
|
||||||
|
id: String,
|
||||||
|
baseUrl: String,
|
||||||
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
|
token: String? = null,
|
||||||
|
): Agent {
|
||||||
|
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
||||||
|
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
|
||||||
|
return wrapWithLocalConversationCache(client, scopeClient = client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
|
||||||
|
* локальным in-memory кэшем, синхронизированным с удалённым стором
|
||||||
|
* через outbox-события.
|
||||||
|
*
|
||||||
|
* - **Seed**: при создании делает один snapshot через
|
||||||
|
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
|
||||||
|
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
|
||||||
|
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
|
||||||
|
*
|
||||||
|
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
|
||||||
|
* (на read-only projection локального [InMemoryMutableConversationStore]).
|
||||||
|
* Остальные методы [Agent] — delegated в [delegate].
|
||||||
|
*/
|
||||||
|
private fun wrapWithLocalConversationCache(
|
||||||
|
delegate: Agent,
|
||||||
|
scopeClient: Agent,
|
||||||
|
): Agent = object : Agent by delegate {
|
||||||
|
|
||||||
|
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
|
||||||
|
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val syncJob: Job
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Делаем cacheStore read-only view на localStore.
|
||||||
|
// (Через вложенный класс — см. ниже.)
|
||||||
|
// Запускаем seed + live-refresh параллельно.
|
||||||
|
syncJob = cacheScope.launch {
|
||||||
|
// 1. seed — snapshot всех текущих бесед с сервера
|
||||||
|
try {
|
||||||
|
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
|
||||||
|
.collect { rec -> localStore.upsert(rec) }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// seed может упасть (offline / 5xx) — не критично,
|
||||||
|
// live-источник всё равно догонит при первом событии.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. live — применяем outbox-события.
|
||||||
|
// Используем `first()` для knownId после Created — потом отписываемся,
|
||||||
|
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
|
||||||
|
// Renamed/Touched меняют локальную копию без round-trip.
|
||||||
|
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
|
||||||
|
when (val ev = ce.event) {
|
||||||
|
is AgentEvent.Created -> {
|
||||||
|
// Created не несёт title/timestamps — нужно сходить в remote.
|
||||||
|
val rec = delegate.conversationStore.get(ev.conversationId)
|
||||||
|
if (rec != null) localStore.upsert(rec)
|
||||||
|
}
|
||||||
|
is AgentEvent.Deleted -> localStore.delete(ev.id)
|
||||||
|
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
|
||||||
|
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only projection локального кэша — клиент через него только
|
||||||
|
* читает (`get` / `list` / `listFlow`).
|
||||||
|
*/
|
||||||
|
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||||
|
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
|
||||||
|
override fun close() {} // owned by outer close
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
cacheScope.cancel()
|
||||||
|
runBlocking { syncJob.join() }
|
||||||
|
delegate.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
-16
@@ -7,16 +7,11 @@ import io.ktor.client.request.parameter
|
|||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -71,17 +66,6 @@ internal class ConversationClient(
|
|||||||
httpClient.post("$convUrl/interrupt")
|
httpClient.post("$convUrl/interrupt")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<Event> = flow {
|
|
||||||
val response = httpClient.get("$convUrl/events?after=$after")
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||||
httpClient.get("$convUrl/messages") {
|
httpClient.get("$convUrl/messages") {
|
||||||
parameter("after", after.toString())
|
parameter("after", after.toString())
|
||||||
+1
-1
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
|
|||||||
internal data class RequestCreateConversation(val temp: Boolean)
|
internal data class RequestCreateConversation(val temp: Boolean)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class RequestRename(val title: String)
|
internal data class RequestRename(val title: String?)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
|
import io.ktor.client.plugins.DefaultRequest
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
|
||||||
|
*
|
||||||
|
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
|
||||||
|
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
|
||||||
|
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
|
||||||
|
* на [pw.binom.agentik.proto.Agent.close].
|
||||||
|
*
|
||||||
|
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
|
||||||
|
* (например, дополнительные нестандартные запросы в обход `Agent` API).
|
||||||
|
*/
|
||||||
|
fun agentikHttpClient(
|
||||||
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
|
token: String? = null,
|
||||||
|
): HttpClient = HttpClient(engineFactory) {
|
||||||
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
|
if (token != null) {
|
||||||
|
install(DefaultRequest) {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [ConversationStore] (read-only metadata view),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint**: `GET {baseUrl}/conversations?offset=&limit=` —
|
||||||
|
* возвращает `List<ConversationRecord>` (id, title, isTemporal, createdAt,
|
||||||
|
* updatedAt) БЕЗ handle'ов и image-support флагов (это лёгкая проекция
|
||||||
|
* для UI-списка; handle берётся через `agent.getConversation(id)`).
|
||||||
|
*
|
||||||
|
* **Read-only**: запись в `conversation` table — только через команды
|
||||||
|
* `agent.createConversation / deleteConversation / renameConversation`.
|
||||||
|
*
|
||||||
|
* Клиентский кэш строится композицией `HttpConversationStore` (snapshot)
|
||||||
|
* + `agent.outbox.agentEvents(after)` (live deltas: Created/Deleted/
|
||||||
|
* Renamed/Touched) — см. `client/README.md` секция
|
||||||
|
* «Кэш списка бесед».
|
||||||
|
*/
|
||||||
|
internal class HttpConversationStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : ConversationStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override suspend fun get(id: String): ConversationRecord? {
|
||||||
|
val response = httpClient.get("$agentUrl/conversations/$id")
|
||||||
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"conversationStore.get($id): server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<ConversationRecord>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
|
||||||
|
val response = httpClient.get("$agentUrl/conversations") {
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"conversationStore.list: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<List<ConversationRecord>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
|
||||||
|
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
|
||||||
|
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
|
||||||
|
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
|
||||||
|
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
|
||||||
|
* `.event` для обратной совместимости с форматом AgentEvent)
|
||||||
|
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
|
||||||
|
*
|
||||||
|
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
|
||||||
|
* fallback на default [OutboxStore.conversationEvents] — общий поток
|
||||||
|
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
|
||||||
|
* оптимизировать его отдельно нерационально.
|
||||||
|
*
|
||||||
|
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
|
||||||
|
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
|
||||||
|
* контрактное значение). Клиент, который полагался на gap detection через
|
||||||
|
* message store, продолжит работать — просто fallback никогда не сработает.
|
||||||
|
*
|
||||||
|
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
|
||||||
|
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
|
||||||
|
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
|
||||||
|
* даёт "Unresolved qualified name"), поэтому приходится использовать
|
||||||
|
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
|
||||||
|
*/
|
||||||
|
internal class HttpEventStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : OutboxStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/outbox/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
|
||||||
|
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
|
||||||
|
*/
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"agentEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
|
||||||
|
emit(CommonEvent.Agent(date = event.date, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
|
||||||
|
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
|
||||||
|
* общий `/events/all` + filter.
|
||||||
|
*/
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> {
|
||||||
|
if (conversationId == null) {
|
||||||
|
return super.conversationEvents(after, null)
|
||||||
|
}
|
||||||
|
return flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/conversations/$conversationId/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"conversationEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(Event.serializer(), payload)
|
||||||
|
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
|
||||||
|
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
|
||||||
|
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
|
||||||
|
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
|
||||||
|
*/
|
||||||
|
override suspend fun earliestEventDate(): Instant = Clock.System.now()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoints** (см. [pw.binom.agentik.server.journalRoutes]):
|
||||||
|
* - `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
|
||||||
|
* → [list]
|
||||||
|
* - `GET {baseUrl}/journal/conversations/{id}/count` → [count] (total)
|
||||||
|
* - `GET {baseUrl}/journal/conversations/{id}/count?after=` → [count] (after cursor)
|
||||||
|
*
|
||||||
|
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
||||||
|
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
||||||
|
* в `:server`'s proto-роутах (который отдаёт project'нутые
|
||||||
|
* [pw.binom.agentik.proto.Message]), здесь клиент получает полный transcript
|
||||||
|
* с tool-call/tool-result/error payload'ами, turn-tokens и context'ом.
|
||||||
|
*
|
||||||
|
* **listFlow** — default cold-flow paging через [list] (N+1 round-trip,
|
||||||
|
* дефолтная реализация из [JournalStore]). Для remote/SQL-backed store'а
|
||||||
|
* это OK: server-side paging + client-side flow compose'ится естественно.
|
||||||
|
*
|
||||||
|
* **Read-only**: [JournalStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип
|
||||||
|
* `MutableJournalStore`, не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
internal class HttpJournalStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : JournalStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.list: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<List<MessageRecord>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String): Long {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count")
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.count: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<CountResponse>().count
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun count(conversationId: String, after: Instant): Long {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/count") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.count(after): server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<CountResponse>().count
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CountResponse(val count: Long)
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.AtomicReference
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
|
||||||
|
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
|
||||||
|
* смешивается с [ReconnectingOutbox.events].
|
||||||
|
*
|
||||||
|
* Типичный цикл:
|
||||||
|
* ```
|
||||||
|
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
|
||||||
|
* Connecting(2) → Connected → ...
|
||||||
|
* ```
|
||||||
|
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
|
||||||
|
* финальный [Failed].
|
||||||
|
*/
|
||||||
|
sealed interface ConnectionStatus {
|
||||||
|
|
||||||
|
/** Начата попытка подключения (включая первую — `attempt == 1`). */
|
||||||
|
data class Connecting(val attempt: Int) : ConnectionStatus
|
||||||
|
|
||||||
|
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
|
||||||
|
data class Connected(val since: Instant) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стрим оборвался (network error, server close, таймаут). [reason] —
|
||||||
|
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
|
||||||
|
* будет следующая попытка (`null` если [Failed]).
|
||||||
|
*/
|
||||||
|
data class Disconnected(
|
||||||
|
val reason: Throwable?,
|
||||||
|
val willRetryIn: Duration?,
|
||||||
|
) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
|
||||||
|
* закрывается после этого. Создатель [ReconnectingOutbox] должен
|
||||||
|
* решить, что делать — показать ошибку пользователю, пересоздать
|
||||||
|
* outbox и т.п.
|
||||||
|
*/
|
||||||
|
data class Failed(val cause: Throwable) : ConnectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Политика backoff для [ReconnectingOutbox]. Параметры:
|
||||||
|
*
|
||||||
|
* - [initial] — задержка перед первой retry-попыткой.
|
||||||
|
* - [max] — потолок задержки (после серии умножений).
|
||||||
|
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
|
||||||
|
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
|
||||||
|
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
|
||||||
|
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
|
||||||
|
*/
|
||||||
|
data class BackoffPolicy(
|
||||||
|
val initial: Duration = 1.seconds,
|
||||||
|
val max: Duration = 30.seconds,
|
||||||
|
val multiplier: Double = 2.0,
|
||||||
|
val maxAttempts: Int = Int.MAX_VALUE,
|
||||||
|
val jitter: Double = 0.2,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(initial > Duration.ZERO) { "initial must be positive" }
|
||||||
|
require(max >= initial) { "max must be >= initial" }
|
||||||
|
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
|
||||||
|
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
|
||||||
|
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
|
||||||
|
val Default: BackoffPolicy = BackoffPolicy()
|
||||||
|
|
||||||
|
/** Только для тестов: фиксированные задержки без разброса. */
|
||||||
|
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
|
||||||
|
BackoffPolicy(
|
||||||
|
initial = delay,
|
||||||
|
max = delay,
|
||||||
|
multiplier = 1.0,
|
||||||
|
maxAttempts = attempts,
|
||||||
|
jitter = 0.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
|
||||||
|
*
|
||||||
|
* **Два независимых потока**:
|
||||||
|
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
|
||||||
|
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
|
||||||
|
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
|
||||||
|
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
|
||||||
|
* lifecycle подключения. Не смешивается с [events].
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val outbox = ReconnectingOutbox(httpEventStore, scope)
|
||||||
|
*
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
|
||||||
|
* }
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // На выходе:
|
||||||
|
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
|
||||||
|
* привязан к нему. Закрытие scope (или явный [close]) отменяет
|
||||||
|
* background-loop. После [close] оба flow терминируются.
|
||||||
|
*/
|
||||||
|
class ReconnectingOutbox(
|
||||||
|
private val outbox: OutboxStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val policy: BackoffPolicy = BackoffPolicy.Default,
|
||||||
|
private val random: Random = Random.Default,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<CommonEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private val _status = MutableSharedFlow<ConnectionStatus>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
|
||||||
|
* учитывается только при первом вызове (любом из [events] /
|
||||||
|
* [connectionStatus]). После reconnect курсор берётся из `date`
|
||||||
|
* последнего виденного события.
|
||||||
|
*
|
||||||
|
* Коллекторы независимы — каждый получает свою копию потока (shared).
|
||||||
|
* Медленный коллектор может пропускать события при переполнении буфера
|
||||||
|
* (`DROP_OLDEST`).
|
||||||
|
*/
|
||||||
|
fun events(after: Instant? = null): Flow<CommonEvent> {
|
||||||
|
ensureStarted(after)
|
||||||
|
return _events
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle подключения: [ConnectionStatus.Connecting] /
|
||||||
|
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
|
||||||
|
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
|
||||||
|
* отдельный поток для UI-индикации статуса сети.
|
||||||
|
*/
|
||||||
|
fun connectionStatus(): Flow<ConnectionStatus> {
|
||||||
|
ensureStarted(null)
|
||||||
|
return _status
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private fun ensureStarted(initialCursor: Instant?) {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
lastSeen.store(initialCursor)
|
||||||
|
job = scope.launch { runLoop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
var connected = false
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
attempt++
|
||||||
|
_status.emit(ConnectionStatus.Connecting(attempt))
|
||||||
|
val error: Throwable? = try {
|
||||||
|
outbox.events(after = lastSeen.load()).collect { event ->
|
||||||
|
lastSeen.store(event.date)
|
||||||
|
_events.emit(event)
|
||||||
|
if (!connected) {
|
||||||
|
connected = true
|
||||||
|
_status.emit(ConnectionStatus.Connected(event.date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (t: CancellationException) {
|
||||||
|
throw t
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
connected = false
|
||||||
|
if (attempt >= policy.maxAttempts) {
|
||||||
|
_status.emit(
|
||||||
|
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val backoff = computeBackoff(attempt)
|
||||||
|
_status.emit(ConnectionStatus.Disconnected(error, backoff))
|
||||||
|
delay(backoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeBackoff(attempt: Int): Duration {
|
||||||
|
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
|
||||||
|
val base = (policy.initial.inWholeMilliseconds.toDouble() *
|
||||||
|
policy.multiplier.pow((attempt - 1).toDouble()))
|
||||||
|
.toLong()
|
||||||
|
val capped = min(base, policy.max.inWholeMilliseconds)
|
||||||
|
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
|
||||||
|
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
|
||||||
|
return Duration.parse("${finalMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||||
|
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||||
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||||
|
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||||
|
*
|
||||||
|
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
||||||
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
|
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||||
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
|
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
||||||
|
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||||
|
*
|
||||||
|
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||||
|
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||||
|
* инстанс мог бы утечь между запросами.
|
||||||
|
*/
|
||||||
|
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
||||||
|
setCapability(
|
||||||
|
HttpTimeoutCapability,
|
||||||
|
HttpTimeoutConfig(
|
||||||
|
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.application.createRouteScopedPlugin
|
||||||
|
import io.ktor.server.cio.CIO as ServerCIO
|
||||||
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
|
||||||
|
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||||
|
* без токена — заголовок не отправляется.
|
||||||
|
*
|
||||||
|
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
|
||||||
|
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
|
||||||
|
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
|
||||||
|
* минимально, контракт тот же.
|
||||||
|
*/
|
||||||
|
class BearerHeaderTest {
|
||||||
|
|
||||||
|
private val TestBearer = createRouteScopedPlugin(
|
||||||
|
name = "TestBearer",
|
||||||
|
createConfiguration = ::BearerCfg,
|
||||||
|
) {
|
||||||
|
val expected = pluginConfig.token
|
||||||
|
onCall { call ->
|
||||||
|
if (expected == null) return@onCall
|
||||||
|
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||||
|
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BearerCfg {
|
||||||
|
var token: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clientWith(token: String?): HttpClient =
|
||||||
|
agentikHttpClient(engineFactory = CIO, token = token)
|
||||||
|
|
||||||
|
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||||
|
val server = embeddedServer(ServerCIO, port = 0) {
|
||||||
|
routing {
|
||||||
|
route("/agentik") {
|
||||||
|
install(TestBearer) { token = "secret" }
|
||||||
|
get("/conversations") {
|
||||||
|
call.respondText("[]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
val port = server.engine.resolvedConnectors().first().port
|
||||||
|
return server to port
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
||||||
|
val (server, port) = startServer()
|
||||||
|
try {
|
||||||
|
val client = clientWith("secret")
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
|
assertEquals("[]", resp.bodyAsText())
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
||||||
|
val (server, port) = startServer()
|
||||||
|
try {
|
||||||
|
val client = clientWith(null)
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
||||||
|
val (server, port) = startServer()
|
||||||
|
try {
|
||||||
|
val client = clientWith("wrong")
|
||||||
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
|
||||||
|
*
|
||||||
|
* Управление:
|
||||||
|
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
|
||||||
|
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
|
||||||
|
* (симулирует network error / stream break).
|
||||||
|
*
|
||||||
|
* Сигнатура [events] идентична боевой — её можно подменить боевым
|
||||||
|
* `HttpEventStore`, контракт один и тот же.
|
||||||
|
*/
|
||||||
|
internal class FakeOutbox : OutboxStore {
|
||||||
|
private sealed interface Msg {
|
||||||
|
data class Ev(val event: CommonEvent) : Msg
|
||||||
|
data class Err(val throwable: Throwable) : Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
private val channel = Channel<Msg>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
for (msg in channel) {
|
||||||
|
when (msg) {
|
||||||
|
is Msg.Err -> throw msg.throwable
|
||||||
|
is Msg.Ev -> emit(msg.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
|
||||||
|
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
|
||||||
|
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> = emptyFlow()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() { channel.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testEvent(dateMs: Long): CommonEvent =
|
||||||
|
CommonEvent.Conversation(
|
||||||
|
date = Instant.fromEpochMilliseconds(dateMs),
|
||||||
|
conversationId = "test",
|
||||||
|
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ReconnectingOutboxTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connected })
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
|
||||||
|
runConnectionTest(attempts = 5) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
|
||||||
|
// Имитируем обрыв стрима после первого события.
|
||||||
|
fake.throwAtNextEvent(RuntimeException("simulated network error"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
// После Disconnected должен прийти Connecting(2), затем Connected,
|
||||||
|
// затем новые события без дубля предыдущего.
|
||||||
|
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
|
||||||
|
val connecting2Index = status.indexOfFirst {
|
||||||
|
it is ConnectionStatus.Connecting && it.attempt == 2
|
||||||
|
}
|
||||||
|
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
|
||||||
|
assertTrue(connecting2Index > disconnectedIndex,
|
||||||
|
"expected Connecting(2) after Disconnected, got: $status")
|
||||||
|
|
||||||
|
// Push a new event with later date — cursor preserves lastSeen.
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(2, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
|
||||||
|
attempts = 3,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
|
||||||
|
// Каждая попытка connect бросает — все 3 попытки fail.
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
|
||||||
|
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
|
||||||
|
assertTrue(failed.cause is RuntimeException)
|
||||||
|
assertEquals(0, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `close cancels background loop`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, ctx.eventsLog.size)
|
||||||
|
|
||||||
|
ctx.recon.close()
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
|
||||||
|
// После close запуск новых эмиссий не должен происходить.
|
||||||
|
val beforePush = ctx.eventsLog.size
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
assertEquals(beforePush, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TestCtx(
|
||||||
|
val fake: FakeOutbox,
|
||||||
|
val recon: ReconnectingOutbox,
|
||||||
|
val statusLog: MutableList<ConnectionStatus>,
|
||||||
|
val eventsLog: MutableList<CommonEvent>,
|
||||||
|
val jobs: List<Job>,
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
val advanceAndDrain: (Long) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
|
||||||
|
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
|
||||||
|
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun runConnectionTest(
|
||||||
|
attempts: Int,
|
||||||
|
block: suspend (TestCtx) -> Unit,
|
||||||
|
) = runTest {
|
||||||
|
val policy = BackoffPolicy.Fixed(
|
||||||
|
delay = Duration.parse("10ms"),
|
||||||
|
attempts = attempts,
|
||||||
|
)
|
||||||
|
val fake = FakeOutbox()
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = fake,
|
||||||
|
scope = this,
|
||||||
|
policy = policy,
|
||||||
|
)
|
||||||
|
val statusLog = mutableListOf<ConnectionStatus>()
|
||||||
|
val eventsLog = mutableListOf<CommonEvent>()
|
||||||
|
val jobs = listOf(
|
||||||
|
launch { recon.connectionStatus().collect { statusLog.add(it) } },
|
||||||
|
launch { recon.events().collect { eventsLog.add(it) } },
|
||||||
|
)
|
||||||
|
val advanceAndDrain: (Long) -> Unit = { ms ->
|
||||||
|
if (ms > 0) advanceTimeBy(ms)
|
||||||
|
runCurrent()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
|
||||||
|
} finally {
|
||||||
|
recon.close()
|
||||||
|
jobs.forEach { it.cancel() }
|
||||||
|
fake.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.response.respondBytesWriter
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.utils.io.writeStringUtf8
|
||||||
|
import io.ktor.utils.io.readUTF8Line
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
||||||
|
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
|
||||||
|
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
||||||
|
* перед каждым read-стримом.
|
||||||
|
*
|
||||||
|
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
|
||||||
|
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
|
||||||
|
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
|
||||||
|
* сообщение доходит.
|
||||||
|
*
|
||||||
|
* Читаем строки пока не найдём "data: done" или пока не сработает
|
||||||
|
* [withTimeout] (18 с — запас над server delay 20 с).
|
||||||
|
*/
|
||||||
|
class SseTimeoutTest {
|
||||||
|
|
||||||
|
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/sse") {
|
||||||
|
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||||
|
writeStringUtf8("data: hello\n\n")
|
||||||
|
flush()
|
||||||
|
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
|
||||||
|
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
|
||||||
|
delay(17_000)
|
||||||
|
writeStringUtf8("data: done\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val received = mutableListOf<String>()
|
||||||
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
|
header("Accept", "text/event-stream")
|
||||||
|
noSseReadTimeout()
|
||||||
|
}.execute { resp ->
|
||||||
|
val ch = resp.bodyAsChannel()
|
||||||
|
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
||||||
|
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
|
||||||
|
val deadline = 19_000L
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - start < deadline) {
|
||||||
|
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
received.add(line)
|
||||||
|
}
|
||||||
|
if (line == "data: done") break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
|
||||||
|
assertTrue(
|
||||||
|
received.contains("data: done"),
|
||||||
|
"должно получить done (SSE read не должен падать на 15 с): $received",
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
received.any { it == "<timeout>" },
|
||||||
|
"SSE read упал в timeout (capability не сработал): $received",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
|
||||||
|
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
||||||
|
* Сервер держит stream 17 с; если клиент не выставил capability —
|
||||||
|
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
||||||
|
* дожидаясь "done".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
get("/sse") {
|
||||||
|
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||||
|
writeStringUtf8("data: hello\n\n")
|
||||||
|
flush()
|
||||||
|
delay(17_000)
|
||||||
|
writeStringUtf8("data: done\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = HttpClient(CIO)
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
try {
|
||||||
|
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||||
|
header("Accept", "text/event-stream")
|
||||||
|
// НАМЕРЕННО без noSseReadTimeout.
|
||||||
|
}.execute { resp ->
|
||||||
|
val ch = resp.bodyAsChannel()
|
||||||
|
// Читаем строки, пока не придёт "data: done" — без capability
|
||||||
|
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
|
||||||
|
while (true) {
|
||||||
|
val line = ch.readUTF8Line() ?: break
|
||||||
|
if (line == "data: done") break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("без capability клиент должен словить HttpRequestTimeoutException")
|
||||||
|
} catch (e: HttpRequestTimeoutException) {
|
||||||
|
val elapsed = System.currentTimeMillis() - start
|
||||||
|
assertTrue(
|
||||||
|
elapsed in 14_000..17_000,
|
||||||
|
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package pw.binom.agentik.client
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import pw.binom.agentik.proto.Agent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
|
||||||
* (модуль `:server`).
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* val client = AgentikAgent(
|
|
||||||
* id = "my-agent",
|
|
||||||
* baseUrl = "http://localhost:8080/agentik",
|
|
||||||
* )
|
|
||||||
* val conv = client.createConversation(temp = false)
|
|
||||||
* conv.send(listOf(Content.Text("hi")))
|
|
||||||
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
|
||||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
|
||||||
* конфига).
|
|
||||||
*
|
|
||||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
|
|
||||||
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
|
|
||||||
*/
|
|
||||||
fun AgentikAgent(
|
|
||||||
id: String,
|
|
||||||
baseUrl: String,
|
|
||||||
httpClient: HttpClient = defaultAgentikHttpClient(),
|
|
||||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
|
|
||||||
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
|
|
||||||
* (см. [readSse]) живёт в общем коде и плагина не требует.
|
|
||||||
*/
|
|
||||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) { json(agentikJson) }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API для runtime context агента (compaction, order_idx, summary entries).
|
||||||
|
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
|
||||||
|
// payload'ы, которые рабочая память ссылает).
|
||||||
|
//
|
||||||
|
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
|
||||||
|
// будущий `:android-agent` core).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||||
* Агент не должен с этим типом работать напрямую — он работает с
|
* Агент не должен с этим типом работать напрямую — он работает с
|
||||||
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
* [WorkingMemoryEntry] через [ContextStore].
|
||||||
*/
|
*/
|
||||||
data class WorkingMemoryRow(
|
data class WorkingMemoryRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
|
|||||||
*
|
*
|
||||||
* Суммаризация / чистка — один атомарный вызов [compact].
|
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||||
*/
|
*/
|
||||||
interface WorkingMemoryStore : AutoCloseable {
|
interface ContextStore : AutoCloseable {
|
||||||
|
|
||||||
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
|
||||||
|
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
|
||||||
|
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
|
||||||
|
// других ksqlite-модулях; этот модуль не претендует на полную схему
|
||||||
|
// агента.
|
||||||
|
//
|
||||||
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
|
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.3 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.3")
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
api(project(":context-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
// :context-api ссылается на Content / MessageContext из
|
||||||
|
// :message-log-api (старый canonical). Транзитивно через api,
|
||||||
|
// но фиксируем явно чтобы тестовый код видел Content без
|
||||||
|
// обхода через :context-api.
|
||||||
|
// api(project(":message-log-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryRow
|
||||||
|
import pw.binom.agentik.journal.Ids
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||||
|
*
|
||||||
|
* Единственный владелец таблицы `working_memory` в проекте. Используется
|
||||||
|
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
|
||||||
|
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||||
|
*
|
||||||
|
* ## Lifecycle соединения
|
||||||
|
*
|
||||||
|
* Семантика владения connection'ом идентична
|
||||||
|
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
|
||||||
|
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
|
||||||
|
* закрывает его в [close].
|
||||||
|
* - `KsqliteContextStore(path)` — открывает файловое соединение,
|
||||||
|
* закрывает его в [close].
|
||||||
|
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
|
||||||
|
*
|
||||||
|
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
|
||||||
|
*/
|
||||||
|
class KsqliteContextStore private constructor(
|
||||||
|
private val connection: SQLiteConnection,
|
||||||
|
private val ownsConnection: Boolean,
|
||||||
|
) : ContextStore {
|
||||||
|
|
||||||
|
constructor(path: String) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(connection: SQLiteConnection) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
|
||||||
|
// SIGSEGV в StmtHolder.finalize на закрытой connection).
|
||||||
|
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
ORDER BY ${Schema.COL_ORDER_IDX} ASC
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, NULL, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
val newIdx = maxOrderIdx(conversationId) + 1
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, Ids.new("wm"))
|
||||||
|
insertStmt.bindText(2, conversationId)
|
||||||
|
insertStmt.bindLong(3, newIdx)
|
||||||
|
val srcId = entry.sourceMessageId
|
||||||
|
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
|
||||||
|
insertStmt.bindText(5, entryKind(entry))
|
||||||
|
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
|
||||||
|
insertStmt.bindLong(7, now.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindText(1, conversationId)
|
||||||
|
val out = mutableListOf<WorkingMemoryRow>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(
|
||||||
|
WorkingMemoryRow(
|
||||||
|
id = rs.getText(0)!!,
|
||||||
|
conversationId = rs.getText(1)!!,
|
||||||
|
orderIdx = rs.getLong(2)!!,
|
||||||
|
sourceMessageId = rs.getText(3),
|
||||||
|
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
clearStmt.reset()
|
||||||
|
clearStmt.clearBindings()
|
||||||
|
clearStmt.bindText(1, conversationId)
|
||||||
|
clearStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun compact(
|
||||||
|
dropFromOrderIdx: Long,
|
||||||
|
conversationId: String,
|
||||||
|
summaryText: String?,
|
||||||
|
): Long = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
var newMax = 0L
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
|
val summaryId = Ids.new("wm")
|
||||||
|
connection.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
dropFromIdxStmt.reset()
|
||||||
|
dropFromIdxStmt.clearBindings()
|
||||||
|
dropFromIdxStmt.bindText(1, conversationId)
|
||||||
|
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
|
||||||
|
dropFromIdxStmt.executeUpdate()
|
||||||
|
|
||||||
|
if (!summaryText.isNullOrBlank()) {
|
||||||
|
val afterDelete = maxOrderIdx(conversationId)
|
||||||
|
val newIdx = afterDelete + 1
|
||||||
|
insertSummaryStmt.reset()
|
||||||
|
insertSummaryStmt.clearBindings()
|
||||||
|
insertSummaryStmt.bindText(1, summaryId)
|
||||||
|
insertSummaryStmt.bindText(2, conversationId)
|
||||||
|
insertSummaryStmt.bindLong(3, newIdx)
|
||||||
|
insertSummaryStmt.bindText(4, "summary")
|
||||||
|
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
|
||||||
|
insertSummaryStmt.bindLong(6, nowMs)
|
||||||
|
insertSummaryStmt.executeUpdate()
|
||||||
|
newMax = newIdx
|
||||||
|
} else {
|
||||||
|
newMax = maxOrderIdx(conversationId)
|
||||||
|
}
|
||||||
|
connection.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { connection.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
newMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
clearStmt.close()
|
||||||
|
maxOrderIdxStmt.close()
|
||||||
|
dropFromIdxStmt.close()
|
||||||
|
insertSummaryStmt.close()
|
||||||
|
if (ownsConnection) connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun memory(name: String? = null): KsqliteContextStore =
|
||||||
|
KsqliteContextStore(
|
||||||
|
connection = SQLiteConnection.memory(name),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxOrderIdx(conversationId: String): Long {
|
||||||
|
maxOrderIdxStmt.reset()
|
||||||
|
maxOrderIdxStmt.clearBindings()
|
||||||
|
maxOrderIdxStmt.bindText(1, conversationId)
|
||||||
|
maxOrderIdxStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0) ?: 0L
|
||||||
|
}
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||||
|
is WorkingMemoryEntry.User -> "user"
|
||||||
|
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||||
|
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
|
||||||
|
is WorkingMemoryEntry.Summary -> "summary"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
|
||||||
|
*
|
||||||
|
* Минимум — только то, что относится к `working_memory` (реализация
|
||||||
|
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
|
||||||
|
* `message`, `reflection`) живут в других ksqlite-модулях.
|
||||||
|
*
|
||||||
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
|
*/
|
||||||
|
object Schema {
|
||||||
|
|
||||||
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
// ───── Таблица ─────
|
||||||
|
const val TABLE_WORKING_MEMORY = "working_memory"
|
||||||
|
|
||||||
|
// ───── Колонки ─────
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
|
const val COL_ORDER_IDX = "order_idx"
|
||||||
|
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
|
||||||
|
const val COL_KIND = "kind"
|
||||||
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
|
const val COL_CREATED_AT = "created_at"
|
||||||
|
|
||||||
|
// ───── Индексы ─────
|
||||||
|
const val IDX_WM_UNIQUE = "idx_wm_unique"
|
||||||
|
const val IDX_WM_CONV = "idx_wm_conv"
|
||||||
|
|
||||||
|
private val v1Ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
|
||||||
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
|
$COL_ORDER_IDX INTEGER NOT NULL,
|
||||||
|
$COL_SOURCE_MESSAGE_ID TEXT,
|
||||||
|
$COL_KIND TEXT NOT NULL,
|
||||||
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val v1IndexesDdl = """
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
|
*
|
||||||
|
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||||
|
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||||
|
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||||
|
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||||
|
*
|
||||||
|
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||||
|
*/
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl)
|
||||||
|
conn.exec(v1IndexesDdl)
|
||||||
|
}
|
||||||
|
// future: if (current < 2) { conn.exec(v2Ddl) }
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
||||||
|
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
|
||||||
|
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
|
||||||
|
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||||
|
*/
|
||||||
|
class KsqliteContextStoreTest {
|
||||||
|
|
||||||
|
private lateinit var conn: SQLiteConnection
|
||||||
|
private lateinit var store: KsqliteContextStore
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||||
|
store = KsqliteContextStore(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
store.close()
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
|
||||||
|
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
|
||||||
|
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendAndListReturnsInOrder() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("first"), t)
|
||||||
|
store.append("c1", asstMsg("reply"), t)
|
||||||
|
val list = store.list("c1")
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertEquals(1L, list[0].orderIdx)
|
||||||
|
assertEquals(2L, list[1].orderIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListIsolatesConversations() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("c1-msg"), t)
|
||||||
|
store.append("c2", userMsg("c2-msg"), t)
|
||||||
|
assertEquals(1, store.list("c1").size)
|
||||||
|
assertEquals(1, store.list("c2").size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClearRemovesAllForConversation() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.clear("c1")
|
||||||
|
assertEquals(emptyList(), store.list("c1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactDeletesAndInsertsSummary() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.append("c1", userMsg("c"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
|
||||||
|
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
|
||||||
|
assertEquals(2L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(2, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
assertEquals(2L, remaining[1].orderIdx)
|
||||||
|
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
||||||
|
assertEquals(1L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(1, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ Main.kt
|
|||||||
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||||
summarization-вставка отложена (нужен дизайн-проработка).
|
summarization-вставка отложена (нужен дизайн-проработка).
|
||||||
|
|
||||||
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
**`JournalStore`** — append-only аудит. На каждый ход дописываются
|
||||||
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||||
update/delete кроме каскада из `ConversationStore.delete`.
|
update/delete кроме каскада из `ConversationStore.delete`.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -175,7 +175,7 @@ fun main() {
|
|||||||
|
|
||||||
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||||
|
|
||||||
### `MessageStore`
|
### `JournalStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(record: MessageRecord)
|
suspend fun append(record: MessageRecord)
|
||||||
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
|
|||||||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `WorkingMemoryStore`
|
### `ContextStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
@@ -194,7 +194,7 @@ suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
|||||||
|
|
||||||
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
||||||
|
|
||||||
### `ConversationStore`
|
### `MutableConversationStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun upsert(record: ConversationRecord)
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 01 — Слои модулей (целевое состояние)
|
||||||
|
|
||||||
|
Целевая модульная структура agentik. Снизу вверх:
|
||||||
|
**приложения → runtime → домен → абстракции → платформенные impl**.
|
||||||
|
|
||||||
|

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

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

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

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