Compare commits
26 Commits
0fdc12695e
..
4
| Author | SHA1 | Date | |
|---|---|---|---|
| c486c7f9ab | |||
| 9d310c5fd0 | |||
| 741ad8963d | |||
| 2634e0e204 | |||
| ac5d209fce | |||
| 25771a0c33 | |||
| 78cbe9b463 | |||
| 65e05612a1 | |||
| 850ee99cb6 | |||
| b5b21d146a | |||
| ee0b9d8341 | |||
| 9d826a4e81 | |||
| db3c49099c | |||
| ddd9d076c1 | |||
| 7a47131f6f | |||
| 9196102f68 | |||
| 6b12dd2c5b | |||
| eed1ab9a17 | |||
| b27ac622b4 | |||
| 14b46087dd | |||
| 098c97c7bd | |||
| 4b8e5bb0bd | |||
| d75289ac56 | |||
| 05f7b8fd04 | |||
| 8f616f359f | |||
| 5ad972767d |
+23
-27
@@ -1,9 +1,10 @@
|
||||
# PR / push-build. Прогоняет unit-тесты на JVM и линтер gradle-плагинов.
|
||||
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
|
||||
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
|
||||
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
|
||||
#
|
||||
# Требуемые Gitea Action Secrets: нет (только gradle-cache).
|
||||
# Опционально: GRADLE_DOWNLOAD_TOKEN — если хочется переиспользовать кэш между
|
||||
# репами (через actions/cache + restore-keys).
|
||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||
# release.yml для требуемых переменных.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
@@ -16,6 +17,14 @@ 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
|
||||
@@ -55,8 +64,8 @@ jobs:
|
||||
./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)"
|
||||
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
|
||||
@@ -64,25 +73,12 @@ jobs:
|
||||
./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)"
|
||||
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
|
||||
# Шага "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 (шаги выше).
|
||||
|
||||
+27
-111
@@ -1,12 +1,20 @@
|
||||
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
|
||||
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine", а также
|
||||
# собирает fatjar'ы запускаемых модулей и прикрепляет их к релизу как
|
||||
# бинарные ассеты.
|
||||
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
|
||||
#
|
||||
# Требуемые Gitea Action Variables:
|
||||
# BINOM_REPO_URL — например http://nexus.xx/repository/caffeine/
|
||||
# Требуемые Gitea Action Secrets:
|
||||
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
|
||||
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
|
||||
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
|
||||
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
|
||||
# выполняется локально из исходников (или руками через `./gradlew
|
||||
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
|
||||
# отдельно от этого workflow.
|
||||
#
|
||||
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
|
||||
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
|
||||
# версия берётся из тега каждый раз.
|
||||
#
|
||||
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
|
||||
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
|
||||
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
|
||||
name: release
|
||||
|
||||
on:
|
||||
@@ -17,6 +25,15 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
|
||||
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
|
||||
# падает с "InvalidPathException: Malformed input or input contains unmappable
|
||||
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
|
||||
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
|
||||
env:
|
||||
LANG: C.UTF-8
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
jobs:
|
||||
publish-libraries:
|
||||
name: Publish KMP libraries → caffeine Nexus
|
||||
@@ -26,108 +43,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
- name: Publish libraries (all KMP targets, all modules) to Nexus
|
||||
uses: https://git.binom.pw/subochev/devops/publish@main
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-agentik-
|
||||
|
||||
- name: Publish libraries (all KMP targets, all modules)
|
||||
shell: bash
|
||||
env:
|
||||
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
|
||||
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
|
||||
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
|
||||
run: |
|
||||
./gradlew \
|
||||
"-Pversion=${GITEA_REF_NAME}" \
|
||||
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
|
||||
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
|
||||
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
|
||||
publish \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
build-fatjars:
|
||||
name: Build runnable fatjars
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'adopt'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-agentik-
|
||||
|
||||
- name: Build :standalone shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :standalone:shadowJar \
|
||||
-Pdisable-javadoc=true \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Build :agentik-cli shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :agentik-cli:shadowJar \
|
||||
-Pdisable-javadoc=true \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Build :agentik-tui shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :agentik-tui:shadowJar \
|
||||
-Pdisable-javadoc=true \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
|
||||
- name: Upload fatjars as release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agentik-fatjars
|
||||
path: |
|
||||
standalone/build/libs/standalone-all.jar
|
||||
agentik-cli/build/libs/agentik-cli-all.jar
|
||||
agentik-tui/build/libs/agentik-tui-all.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Attach to release
|
||||
uses: https://git.binom.pw/actions/forgejo-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
url: ${{ github.server_url }}
|
||||
repo: ${{ github.repository }}
|
||||
token: ${{ secrets.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
|
||||
version: ${{ gitea.ref_name }}
|
||||
|
||||
@@ -20,6 +20,9 @@ out/
|
||||
.cortexkit/
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
.tasks/
|
||||
|
||||
# Runtime / test artifacts
|
||||
agentik.db
|
||||
agentik.db-shm
|
||||
|
||||
@@ -1,86 +1,153 @@
|
||||
# agentik
|
||||
|
||||
Self-contained multi-module Kotlin Multiplatform агент с долговременной памятью,
|
||||
персоной, навыками и HTTP-фасадом под `/agentik`. Состоит из библиотечных модулей
|
||||
(KMP, опубликованных в Nexus `caffeine`) и трёх запускаемых артефактов.
|
||||
Локальный stateful LLM-агент с persistent-памятью, инструментами и
|
||||
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
|
||||
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
|
||||
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
|
||||
Qwen) через ONNX/Native-runtime.
|
||||
|
||||
## Запускаемые модули
|
||||
## Что внутри
|
||||
|
||||
| Модуль | Что делает | Артефакт | Таргеты |
|
||||
|---|---|---|---|
|
||||
| [`:standalone`](standalone/README.md) | HTTP-сервер со всеми транспортами (AG-UI / A2A / `:proto`), SQLite, памятью, скилами, SOUL, MCP | `standalone-all.jar` (≈250 MB) | JVM |
|
||||
| [`:agentik-cli`](agentik-cli/README.md) | REPL-клиент к `/agentik` со slash-командами | `agentik-cli-all.jar` (≈8 MB) | JVM |
|
||||
| [`:agentik-tui`](agentik-tui/README.md) | Compose-style TUI-клиент (Mosaic) к `/agentik` | `agentik-tui-all.jar` (≈10 MB) | JVM + macosX64/macosArm64/linuxX64/linuxArm64/mingwX64 |
|
||||
```
|
||||
agentik/
|
||||
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
|
||||
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
|
||||
├── client/ Ktor-клиент → тот же /agentik, с KMP-native
|
||||
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
|
||||
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
|
||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||
├── storage-sqlite/ SQLite реализация для production
|
||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||
```
|
||||
|
||||
## Библиотеки
|
||||
Каждый подмодуль имеет собственный `README.md` с деталями
|
||||
(см. "Модули" ниже).
|
||||
|
||||
Все библиотеки — **KMP (jvm + 8 native)**, опубликованы в Nexus-репо `caffeine`
|
||||
под группой `pw.binom.agentik`.
|
||||
## Quickstart
|
||||
|
||||
### Протокол и транспорт
|
||||
- [`: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`.
|
||||
### 1. Скачать fatjar
|
||||
|
||||
### Память
|
||||
- [`:memory-api`](memory-api/README.md) — контракт: `MemoryStore`, `MemoryNote`, `MemoryPrefetcher`, `MemoryReviewer`, `MemoryTools`.
|
||||
- [`: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).
|
||||
CI артефакты доступны на Gitea через GitHub Actions artifacts на
|
||||
tag-релизах, либо соберите из исходников:
|
||||
|
||||
### Хранилище
|
||||
- [`:storage-core`](storage-core/README.md) — `ConversationStore` / `MessageStore` / `WorkingMemoryStore` / `ReflectionStore`.
|
||||
- [`:storage-inmemory`](storage-inmemory/README.md) — in-memory реализация (для тестов и embedded).
|
||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLDelight реализация (прод-бэкенд).
|
||||
```bash
|
||||
git clone https://git.binom.pw/subochev/agentik
|
||||
cd agentik
|
||||
./gradlew :standalone:shadowJar
|
||||
```
|
||||
|
||||
### Логика
|
||||
- [`:skills`](skills/README.md) — opencode-style `SKILL.md` / `*.yaml` парсер + рендер в system prompt.
|
||||
- [`:agent-toolsets`](agent-toolsets/README.md) — реестр тулов + `enable_toolset`/`disable_toolset` диспетчер.
|
||||
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
|
||||
зависит от LLM-backend'а).
|
||||
|
||||
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=openai \
|
||||
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
|
||||
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
|
||||
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
||||
```
|
||||
|
||||
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
|
||||
|
||||
```bash
|
||||
AGENTIK_LLM_BACKEND=google \
|
||||
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
|
||||
```
|
||||
|
||||
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
|
||||
|
||||
## Подключиться
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||
|
||||
# curl
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Модули
|
||||
|
||||
- Запускаемые:
|
||||
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
|
||||
- Библиотеки (контракты и реализации):
|
||||
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||
- [`:client`](client/README.md) — Ktor-клиент `:server`.
|
||||
- [`:skills`](skills/README.md) — парсер SKILL.md.
|
||||
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||
|
||||
## Где смотреть версии
|
||||
|
||||
- `gradle.properties` → `version=0.1.0` (текущая разрабатываемая)
|
||||
- Релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
- Опубликованные артефакты: Nexus-репозиторий `caffeine`
|
||||
(`http://nexus.xx/repository/caffeine/pw/binom/agentik/`)
|
||||
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
|
||||
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
|
||||
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
|
||||
|
||||
При подключении библиотек используйте одну и ту же `version` (`VERSION` в Gradle
|
||||
зависимостях). Все артефакты синхронизированы и совместимы по ABI в пределах
|
||||
одной версии.
|
||||
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
|
||||
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
|
||||
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
|
||||
тега и публикует.
|
||||
|
||||
## Публикация (CI/CD)
|
||||
## Публикация
|
||||
|
||||
`.gitea/workflows/release.yml` — публикует все KMP-таргеты всех модулей в
|
||||
Nexus-репо `caffeine` при создании Gitea Release. Версия артефактов берётся из
|
||||
имени тега (`git tag v0.1.0` → `pw.binom.agentik:*:0.1.0`).
|
||||
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
|
||||
Параметры через:
|
||||
|
||||
```bash
|
||||
# Создать релиз:
|
||||
git tag v0.1.0 && git push --tags
|
||||
# → Gitea → Releases → New Release → выбрать тег → Publish
|
||||
# → CI публикует в Nexus (нужны секреты BINOM_REPO_USER/BINOM_REPO_PASSWORD)
|
||||
```
|
||||
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
|
||||
- `binom.repo.user`
|
||||
- `binom.repo.password`
|
||||
|
||||
## Сборка
|
||||
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
|
||||
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
|
||||
репозитория). Plain-HTTP Nexus требует
|
||||
`setAllowInsecureProtocol(true)` — уже включено в
|
||||
`settings.gradle.kts`.
|
||||
|
||||
```bash
|
||||
# Всё
|
||||
./gradlew build
|
||||
## CI/CD
|
||||
|
||||
# Только JVM-тесты всех модулей
|
||||
./gradlew jvmTest
|
||||
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||
|
||||
# Только fatjar запускаемых модулей
|
||||
./gradlew :standalone:shadowJar :agentik-cli:shadowJar :agentik-tui:shadowJar
|
||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||
shadowjar'ов.
|
||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||
|
||||
# Опубликовать локально в mavenLocal (~/.m2)
|
||||
./gradlew publishToMavenLocal
|
||||
## Что отличает от других агентских фреймворков
|
||||
|
||||
# Опубликовать в Nexus (нужны креды)
|
||||
./gradlew publish -Pversion=0.2.0 \
|
||||
-Pbinom.repo.url=http://nexus.xx/repository/caffeine/ \
|
||||
-Pbinom.repo.user=USER -Pbinom.repo.password=PASS
|
||||
```
|
||||
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
|
||||
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
|
||||
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
|
||||
Один fatjar — три API.
|
||||
- **Полностью Kotlin Multiplatform** — все контракты компилируются
|
||||
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
|
||||
Desktop / CLI.
|
||||
- **Прерывание tool-calls сохраняется в working memory** — нет
|
||||
потери контекста, если пользователь нажал Ctrl-C во время
|
||||
долгого tool-вызова.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Apache-2.0 — см. [LICENSE](LICENSE) (если есть).
|
||||
Apache-2.0 — смотрите [LICENSE](LICENSE).
|
||||
|
||||
## Участие в проекте
|
||||
|
||||
PR-ы приветствуются. Не забывайте синхронизировать версии в
|
||||
`gradle/libs.versions.toml` и обновлять per-module README при
|
||||
изменении API.
|
||||
|
||||
+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-серверы, кастомные
|
||||
тулы, встроенные операции). Слать их все в каждый LLM-запрос:
|
||||
- `Toolset` — интерфейс, объединяющий несколько связанных tools
|
||||
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
|
||||
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
|
||||
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
|
||||
→ запускает → возвращает результат.
|
||||
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
|
||||
вызов, помечая результат `[cancelled by user]`.
|
||||
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
|
||||
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
|
||||
threadpool от переполнения при fan-out 30+ диалогов.
|
||||
|
||||
1. **Раздувает контекст** — описание тула ~50–200 токенов × 100 тулов = 20K токенов
|
||||
в system prompt без пользы.
|
||||
2. **Увеличивает latency** — модель тратит время на выбор из длинного списка.
|
||||
3. **Снижает качество** — модель путается между похожими названиями.
|
||||
Решает: надёжный механизм tool-calls с прерываниями, без
|
||||
blocking-pool exhaustion, без утечки. Переиспользуется во всех
|
||||
IM-фронтендах (CLI, TUI, IRC, web).
|
||||
|
||||
Toolsets группируют тулы **по домену** (`filesystem`, `network`, `devops`, …).
|
||||
Активированы только 2-3 одновременно. `enable_toolset("filesystem")` —
|
||||
включает целую группу одним обращением; тулы появляются в system prompt +
|
||||
регистрируются как вызываемые. `disable_toolset(...)` — убирает.
|
||||
## Где используется
|
||||
|
||||
## Архитектура
|
||||
- `:standalone` подключает несколько `Toolset`-имплементаций
|
||||
(memory / skills / files / web), фильтрует через
|
||||
`AGENTIK_TOOLSETS_DEFAULT` env.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
interface Toolset {
|
||||
val name: String // "filesystem"
|
||||
val title: String // "File operations"
|
||||
val enabled: Boolean // текущее состояние
|
||||
suspend fun enabledTools(context: ToolsetContext): List<LiteTool>
|
||||
suspend fun systemPromptSection(context: ToolsetContext): String
|
||||
commonMain.dependencies {
|
||||
api("pw.binom.agentik:agent-toolsets:0.1.0")
|
||||
}
|
||||
|
||||
class ToolsetRegistry {
|
||||
fun register(toolset: Toolset)
|
||||
fun list(): List<Toolset>
|
||||
suspend fun enable(name: String): Boolean
|
||||
suspend fun disable(name: String): Boolean
|
||||
class MyToolset : Toolset {
|
||||
override val name = "my"
|
||||
override val description = "Custom user-defined tools"
|
||||
override val tools = listOf(myTool1, myTool2)
|
||||
}
|
||||
|
||||
class ToolsetDispatchPolicy {
|
||||
fun buildDispatch(): DispatchPolicy // подаётся в LiteLlm
|
||||
}
|
||||
val dispatcher = ToolDispatcher(
|
||||
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
|
||||
commonMain {
|
||||
implementation("pw.binom.agentik:agent-toolsets:$version")
|
||||
// Транзитивно: :storage-core (для ToolsetContext) + :proto
|
||||
data object EchoTool : Tool {
|
||||
override val name = "echo"
|
||||
override val description = "Echoes back the argument"
|
||||
override val argsSchema = jsonSchema {
|
||||
property("text", JsonType.STRING) { required = true }
|
||||
}
|
||||
|
||||
override suspend fun invoke(args: JsonObject): ToolResult {
|
||||
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
|
||||
return ToolResult.Text(text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Где смотреть версии
|
||||
## Тесты
|
||||
|
||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./gradlew :agent-toolsets:build
|
||||
```
|
||||
./gradlew :agent-toolsets:allTests
|
||||
```
|
||||
|
||||
KMP-таргеты — полный набор. Зависимости — `:storage-core` + `:proto` +
|
||||
`kotlinx-coroutines` + `kotlinx-serialization`.
|
||||
Покрывают: invoke happy-path, invalid args, cooperative cancel,
|
||||
budget exhaustion, registry filter, parallel dispatch.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
||||
(см. `:storage-core`).
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Реализует полную спецификацию из
|
||||
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
|
||||
log, rolling buffer, partial-state persistence.
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
|
||||
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
||||
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
||||
* чтобы избежать коллизий между разными MCP-серверами.
|
||||
*
|
||||
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
|
||||
* должен жить рядом с другими тулами в `:agent-toolsets`.
|
||||
*/
|
||||
data class NamedTool(val name: String, val tool: LiteTool)
|
||||
+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
|
||||
./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` репо) — см. релизы:
|
||||
`https://git.binom.pw/subochev/agentik/releases`. После публикации нового
|
||||
тега jar появляется как `pw.binom.agentik:agentik-cli:VERSION` (artifact + classifier `all`).
|
||||
|
||||
## Запуск
|
||||
### Native linuxX64
|
||||
|
||||
```bash
|
||||
# По умолчанию — http://localhost:8080/agentik
|
||||
java -jar agentik-cli-all.jar
|
||||
|
||||
# К другому серверу
|
||||
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>
|
||||
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
|
||||
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
|
||||
```
|
||||
|
||||
Альтернативно: `AGENTIK_SERVER` env-переменная с тем же эффектом, что и `--server`.
|
||||
### Native macOS / Windows
|
||||
|
||||
## Slash-команды внутри REPL
|
||||
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
|
||||
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
|
||||
кросс-компиляцию.
|
||||
|
||||
| Команда | Алиасы | Действие |
|
||||
|---|---|---|
|
||||
| `/help` | `/?` | список команд + клавиш |
|
||||
| `/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 — то же) |
|
||||
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
|
||||
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
|
||||
раннере Gitea Actions.
|
||||
|
||||
Свободный текст = сообщение текущему диалогу; SSE-события (start_reasoning /
|
||||
start_response / append_text / end / interrupted / error) стримятся в stdout
|
||||
в реальном времени.
|
||||
## Примеры
|
||||
|
||||
## Переменные окружения
|
||||
```bash
|
||||
# Список диалогов (таблица)
|
||||
agentik-cli conv ls --server http://localhost:8080/agentik
|
||||
|
||||
| Переменная | Дефолт | Назначение |
|
||||
|---|---|---|
|
||||
| `AGENTIK_SERVER` | `http://localhost:8080/agentik` | URL HTTP-фасада `:server` |
|
||||
# Создать диалог
|
||||
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
|
||||
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, история
|
||||
readline в `~/.agentik/.inputrc`-стиле (через JLine `DefaultHistory`).
|
||||
- **Reconnect-safe SSE** — если сервер рестартовал, клиент подхватывает с
|
||||
`lastEventAt` через `events(after)`.
|
||||
- **Snapshot-режим** — если подключились к диалогу впервые, `/history`
|
||||
подгружает старые сообщения через `getMessages(after)` (offline-бэкфилл).
|
||||
# Показать последние N сообщений
|
||||
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
|
||||
|
||||
# Прервать активный ход
|
||||
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
|
||||
|
||||
# Удалить
|
||||
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
|
||||
|
||||
# Через env-переменную
|
||||
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
|
||||
```
|
||||
|
||||
## Формат вывода `send`
|
||||
|
||||
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
|
||||
пригодно для парсинга через `awk`/`jq`-обёртки:
|
||||
|
||||
```
|
||||
event StartReasoning
|
||||
event StartResponse TEXT
|
||||
event AppendText \n\n
|
||||
event AppendText Привет!
|
||||
event End
|
||||
```
|
||||
|
||||
Терминальные события (`End`, `Interrupted`, `Error`) тоже
|
||||
печатаются; CLI выходит сразу после `End`.
|
||||
|
||||
## Почему kotlinx.cli (а не clikt)
|
||||
|
||||
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
|
||||
arg-parser, который стабильно линкуется под `linux_x64` +
|
||||
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
|
||||
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
|
||||
ломается на native linker: `duplicate symbol selfAndAncestors`
|
||||
между `clikt` и `clikt-mordant` commonMain (issue
|
||||
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
|
||||
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
|
||||
сборку на порядки и не решает проблему до конца. Поэтому clikt
|
||||
отвергнут.
|
||||
|
||||
## Платформенные детали
|
||||
|
||||
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
|
||||
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
|
||||
convention `MainKt.main` тут не работает — K/N линкер ищет
|
||||
функцию по `package.main`.
|
||||
- **`platformEnv(key)`** для чтения env-переменных:
|
||||
- JVM: `System.getenv(key)` через `jvmMain` actual.
|
||||
- Native: `getenv(key)` из `platform.posix` через
|
||||
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
|
||||
требует `@OptIn(ExperimentalForeignApi::class)`).
|
||||
- **Stdout / exit code** — работают на K/N через корутины.
|
||||
|
||||
## Готчасы kotlinx.cli
|
||||
|
||||
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
|
||||
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
|
||||
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
|
||||
сделан no-op (`override fun execute() = Unit`), иначе вывод
|
||||
дочерней команды дублируется выводом родителя. Дочерние команды
|
||||
смотрятся через `agentik-cli conv --help`.
|
||||
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
|
||||
парсится как `conv [--server ...]` + позиционный аргумент `new`
|
||||
на уровне родителя — и дочерняя команда не запускается.
|
||||
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
|
||||
|
||||
## Тесты
|
||||
|
||||
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
|
||||
покрывается руками против живого сервера.
|
||||
|
||||
```bash
|
||||
./gradlew :agentik-cli:jvmTest
|
||||
./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 com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
import org.gradle.api.artifacts.ConfigurationContainer
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
@@ -12,67 +11,67 @@ plugins {
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
|
||||
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto.
|
||||
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only)
|
||||
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual,
|
||||
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь.
|
||||
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
|
||||
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
|
||||
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
|
||||
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
|
||||
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
|
||||
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
|
||||
// поэтому clikt отвергнут.
|
||||
//
|
||||
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
|
||||
// его потребитель) тоже без iOS.
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
listOf(
|
||||
linuxX64(),
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
mingwX64(),
|
||||
)
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":proto"))
|
||||
implementation(project(":client"))
|
||||
|
||||
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
|
||||
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
|
||||
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
|
||||
// таких проблем нет.
|
||||
implementation(libs.kotlinx.cli)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// :client JVM-only (ktor-cio). Подключаем только в jvmMain.
|
||||
implementation(project(":client"))
|
||||
// JLine для readline с историей и completion.
|
||||
implementation(libs.jline)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
// runTest { } — suspend test runner для commonTest.
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
// JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4.
|
||||
implementation("junit:junit:4.13.2")
|
||||
}
|
||||
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||
// весь код, включая platformEnv, лежит в commonMain.
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
jvm {
|
||||
binaries {
|
||||
executable {
|
||||
mainClass.set("pw.binom.agentik.cli.MainKt")
|
||||
}
|
||||
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fatjar (uberjar) ---
|
||||
//
|
||||
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
|
||||
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
|
||||
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
|
||||
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
|
||||
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
|
||||
// mainClass.set(...) выше.
|
||||
listOf(
|
||||
linuxX64(),
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
mingwX64(),
|
||||
).forEach {
|
||||
it.binaries.executable {
|
||||
entryPoint = "pw.binom.agentik.cli.main"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fatjar — аналог :standalone.
|
||||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||
archiveBaseName.set("agentik-cli")
|
||||
archiveClassifier.set("all")
|
||||
@@ -80,20 +79,13 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||
group = "build"
|
||||
|
||||
from(tasks.named("jvmJar"))
|
||||
val cc = try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||
} catch (_: ClassCastException) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||
}
|
||||
from(cc.getByName("jvmRuntimeClasspath"))
|
||||
from(project.configurations.getByName("jvmRuntimeClasspath"))
|
||||
|
||||
mergeServiceFiles()
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
|
||||
manifest {
|
||||
attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt"
|
||||
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
|
||||
attributes["Implementation-Title"] = "agentik-cli"
|
||||
attributes["Implementation-Version"] = project.version.toString()
|
||||
}
|
||||
|
||||
@@ -1,323 +1,87 @@
|
||||
package pw.binom.agentik.cli
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.cli.ArgParser
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.ExperimentalCli
|
||||
import kotlinx.cli.Subcommand
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.commands.ConvCommand
|
||||
import pw.binom.agentik.cli.commands.InfoSubcommand
|
||||
import pw.binom.agentik.cli.commands.InterruptSubcommand
|
||||
import pw.binom.agentik.cli.commands.MsgsSubcommand
|
||||
import pw.binom.agentik.cli.commands.SendSubcommand
|
||||
|
||||
/**
|
||||
* Главный класс REPL.
|
||||
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
|
||||
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
|
||||
*
|
||||
* Управляет:
|
||||
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
|
||||
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
|
||||
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
|
||||
*
|
||||
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
|
||||
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
|
||||
* увидит [Event.Interrupted] и сам завершится.
|
||||
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
|
||||
* `--server`/`--id` (иначе subcommand не видит значения родителя).
|
||||
*/
|
||||
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(
|
||||
historyFile = if (config.historyEnabled) stateFilePath() else null,
|
||||
prompt = "agentik> ",
|
||||
)
|
||||
private val sessionRepo = SessionRepository(
|
||||
filePath = if (config.historyEnabled) stateFilePath() else null,
|
||||
io = CliPlatform.sessionIo(),
|
||||
/**
|
||||
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
|
||||
*
|
||||
* ```
|
||||
* agentik-cli <command> [args...]
|
||||
*
|
||||
* Commands:
|
||||
* conv ls|new|show|delete|rename операции над диалогами
|
||||
* msgs <id> [--limit N] показать сообщения
|
||||
* send <id> <text...> отправить ход, стримит response-события в stdout
|
||||
* interrupt <id> прервать текущий ход
|
||||
* info показать конфиг
|
||||
*
|
||||
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
|
||||
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
|
||||
* шарит опции родителя в subcommand.
|
||||
*
|
||||
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
|
||||
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
|
||||
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
|
||||
* регистрируются у него.
|
||||
*/
|
||||
@OptIn(ExperimentalCli::class)
|
||||
fun runAgentikCli(args: Array<String>) {
|
||||
val parser = ArgParser(
|
||||
programName = "agentik-cli",
|
||||
// Все аргументы после имени subcommand должны передаваться
|
||||
// В subcommand-парсер, а не парситься на уровне родителя.
|
||||
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
|
||||
// + аргумент "new" → execute родителя, без вложенной команды.
|
||||
strictSubcommandOptionsOrder = true,
|
||||
)
|
||||
|
||||
private var currentConv: Conversation? = null
|
||||
private var currentTitle: String? = null
|
||||
private var lastEventAt: Instant = Instant.DISTANT_PAST
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
suspend fun run() {
|
||||
try {
|
||||
// Восстановление сессии.
|
||||
val saved = sessionRepo.load()
|
||||
if (saved != null) {
|
||||
val conv = runCatching { agent.getConversation(saved.conversationId) }
|
||||
.getOrNull()
|
||||
if (conv != null) {
|
||||
currentConv = conv
|
||||
currentTitle = conv.title
|
||||
lastEventAt = saved.lastEventAt
|
||||
terminal.printSystem(
|
||||
"восстановлен диалог ${shorten(conv.id)}" +
|
||||
" (${conv.title ?: "без названия"})",
|
||||
val conv = ConvCommand()
|
||||
parser.subcommands(
|
||||
conv,
|
||||
MsgsSubcommand(),
|
||||
SendSubcommand(),
|
||||
InterruptSubcommand(),
|
||||
InfoSubcommand(),
|
||||
)
|
||||
} else {
|
||||
terminal.printSystem(
|
||||
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
|
||||
)
|
||||
}
|
||||
|
||||
parser.parse(args)
|
||||
}
|
||||
|
||||
printBanner()
|
||||
|
||||
// Главный цикл.
|
||||
while (scope.isActive) {
|
||||
terminal.print(prompt())
|
||||
val line = terminal.readLine() ?: break // EOF → выходим
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
when (val r = parseSlash(trimmed.substring(1))) {
|
||||
is ParseResult.Success -> {
|
||||
if (handleCommand(r.command) == CommandResult.Exit) break
|
||||
}
|
||||
is ParseResult.Failure -> terminal.printSystem(r.message)
|
||||
}
|
||||
} else {
|
||||
handleUserMessage(trimmed)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
terminal.printSystem("до свидания.")
|
||||
currentConv?.close()
|
||||
terminal.close()
|
||||
sessionRepo.close()
|
||||
scope.cancel()
|
||||
}
|
||||
/**
|
||||
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
|
||||
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
|
||||
* свойства родителя в subcommand).
|
||||
*/
|
||||
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
|
||||
val serverUrl: String by option(
|
||||
ArgType.String, fullName = "server", shortName = "s",
|
||||
description = "Base URL агента (env AGENTIK_SERVER)",
|
||||
).default(defaultServerUrl())
|
||||
val agentId: String by option(
|
||||
ArgType.String, fullName = "id", shortName = "i",
|
||||
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
|
||||
).default(defaultAgentId())
|
||||
}
|
||||
|
||||
// ============================================================ banner / prompt
|
||||
|
||||
private suspend fun printBanner() {
|
||||
terminal.println()
|
||||
terminal.println("agentik-cli — id=${config.id} — type /help")
|
||||
terminal.println("server: ${config.server}")
|
||||
when (val c = currentConv) {
|
||||
null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>")
|
||||
else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})")
|
||||
fun main(args: Array<String>) {
|
||||
runAgentikCli(args)
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val ok = agent.deleteConversation(id)
|
||||
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов агента") {
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||
if (convs.isEmpty()) {
|
||||
println("(no conversations)")
|
||||
return@runBlocking
|
||||
}
|
||||
println("ID UPDATED-AT TITLE FLAGS")
|
||||
convs.forEach { c ->
|
||||
val flags = buildString {
|
||||
if (c.isTemporal) append('T')
|
||||
if (c.isSupportImageInput) append('I')
|
||||
if (c.isSupportImageOutput) append('O')
|
||||
if (isEmpty()) append('-')
|
||||
}
|
||||
val title = c.title ?: "(untitled)"
|
||||
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
|
||||
}
|
||||
println("--- ${convs.size} conversation(s)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.createConversation(temp = temp)
|
||||
println(conv.id)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val title by argument(ArgType.String, description = "Новое название")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
conv.rename(title)
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
println("renamed: $id -> $title")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
println("id: ${conv.id}")
|
||||
println("title: ${conv.title ?: "(untitled)"}")
|
||||
println("updatedAt: ${conv.updatedAt}")
|
||||
println("isTemporal: ${conv.isTemporal}")
|
||||
println("isSupportImageInput: ${conv.isSupportImageInput}")
|
||||
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
|
||||
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
|
||||
override fun execute() {
|
||||
println("server: $serverUrl")
|
||||
println("id: $agentId")
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
|
||||
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
conv.interrupt()
|
||||
println("interrupted: $id")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.default
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Message
|
||||
import kotlin.time.Instant
|
||||
|
||||
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
|
||||
.sortedBy { it.date }
|
||||
msgs.forEach { m -> println(formatMessage(m)) }
|
||||
println("--- ${msgs.size} message(s)")
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMessage(m: Message): String =
|
||||
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
|
||||
|
||||
private fun Message.role(): String = when (this) {
|
||||
is Message.UserMessage -> "[user]"
|
||||
is Message.AssistantMessage -> "[assistant]"
|
||||
is Message.ToolCall -> "[tool_call]"
|
||||
is Message.ToolResult -> "[tool_result]"
|
||||
is Message.Error -> "[error]"
|
||||
}
|
||||
|
||||
private fun Message.bodyOneLine(): String = when (this) {
|
||||
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
|
||||
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
|
||||
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
|
||||
is Message.Error -> "code=${code ?: "?"} message=$message"
|
||||
}
|
||||
|
||||
private fun Content.toOneLine(): String = when (this) {
|
||||
is Content.Text -> body.replace('\n', ' ').take(200)
|
||||
is Content.Image -> "<image ${data.size}B $mime>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package pw.binom.agentik.cli.commands
|
||||
|
||||
import kotlinx.cli.ArgType
|
||||
import kotlinx.cli.vararg
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.cli.AgentikSubcommand
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||
val id by argument(ArgType.String, description = "ID диалога")
|
||||
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||
|
||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
||||
val conv = agent.getConversation(id) ?: run {
|
||||
println("conversation not found: $id")
|
||||
return@runBlocking
|
||||
}
|
||||
try {
|
||||
// Подписываемся на поток событий ДО send: события, отправленные
|
||||
// до подписки, не реплеятся (shared-flow без replay).
|
||||
val eventsJob = launch {
|
||||
conv.events(Instant.DISTANT_PAST)
|
||||
// onEach печатает и терминальный event, takeWhile лишь
|
||||
// завершает сбор после него.
|
||||
.onEach { ev -> emit(ev) }
|
||||
.takeWhile { ev -> !isTerminal(ev) }
|
||||
.collect { }
|
||||
}
|
||||
// Даём SSE-подписке установиться, затем шлём ход.
|
||||
delay(200)
|
||||
conv.send(listOf(Content.Text(text.joinToString(" "))))
|
||||
eventsJob.join()
|
||||
} finally {
|
||||
conv.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminal(ev: Event): Boolean =
|
||||
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
|
||||
|
||||
private fun emit(ev: Event) {
|
||||
when (ev) {
|
||||
is Event.StartReasoning -> println("event StartReasoning")
|
||||
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
|
||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
||||
is Event.End -> println("event End")
|
||||
is Event.Interrupted -> println("event Interrupted")
|
||||
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
|
||||
}
|
||||
@@ -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
|
||||
./gradlew :agentik-tui:shadowJar
|
||||
# Результат: agentik-tui/build/libs/agentik-tui-all.jar (~10 MB)
|
||||
java --enable-native-access=ALL-UNNAMED \
|
||||
-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
|
||||
# По умолчанию — 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>
|
||||
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
|
||||
```
|
||||
|
||||
Альтернативно: `AGENTIK_SERVER` env-переменная.
|
||||
## Параметры CLI
|
||||
|
||||
> **`--enable-native-access=ALL-UNNAMED`** обязателен: Mosaic использует
|
||||
> нативные 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-командами).
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Дефолт | Назначение |
|
||||
| Флаг | ENV | Что делает |
|
||||
|---|---|---|
|
||||
| `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+.
|
||||
⌛ split-pane (history left / input right) — пока input внизу, full-width.
|
||||
⌛ `:agentik-cli`-slash-команды внутри TUI (history backfill / list / switch).
|
||||
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
|
||||
же, что для `:agentik-cli`.
|
||||
2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
|
||||
built-in mouse-runtime). Планируется в v3 через termios
|
||||
SGR-mouse.
|
||||
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
|
||||
собраны, но без `:client` (он JVM-only). Для нативной работы
|
||||
нужен альтернативный HTTP-клиент.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
```
|
||||
./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()
|
||||
mingwX64()
|
||||
|
||||
// Native executables. По умолчанию Kotlin/Native для каждого target'а
|
||||
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
|
||||
// явно попросить binaries.executable(). entryPoint нужно задать явно:
|
||||
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
|
||||
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
|
||||
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
|
||||
//
|
||||
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
|
||||
// явно (а не через targets.withType), потому что targets DSL в KMP не
|
||||
// поддерживает реифицированный withType<KotlinNativeTarget>().
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
|
||||
it.binaries.executable {
|
||||
entryPoint = "pw.binom.agentik.tui.main"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project(":proto"))
|
||||
@@ -34,6 +51,11 @@ kotlin {
|
||||
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||
implementation(libs.mosaic.runtime)
|
||||
implementation(libs.mosaic.tty.terminal)
|
||||
|
||||
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
|
||||
// там работает stub actual через expect/actual.
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(project(":client"))
|
||||
@@ -41,6 +63,7 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.layout.KeyEvent
|
||||
import com.jakewharton.mosaic.layout.drawBehind
|
||||
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Box
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Row
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
import pw.binom.agentik.tui.ui.Footer
|
||||
import pw.binom.agentik.tui.ui.Header
|
||||
import pw.binom.agentik.tui.ui.HelpOverlay
|
||||
import pw.binom.agentik.tui.ui.HistoryPanel
|
||||
import pw.binom.agentik.tui.ui.InputLine
|
||||
|
||||
/**
|
||||
* Корневая Compose-композиция TUI.
|
||||
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
|
||||
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
|
||||
*
|
||||
* Layout (минимальный):
|
||||
* ```
|
||||
@@ -28,6 +29,9 @@ import com.jakewharton.mosaic.ui.TextStyle
|
||||
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||
* └─────────────────────────────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun App(state: AppState) {
|
||||
@@ -50,105 +54,8 @@ internal fun App(state: AppState) {
|
||||
Header(state, focusIndex)
|
||||
HistoryPanel(state)
|
||||
InputLine(state)
|
||||
Footer(state, showHelp)
|
||||
Footer(showHelp)
|
||||
}
|
||||
}
|
||||
if (showHelp) HelpOverlay()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(state: AppState, focusIndex: Int) {
|
||||
val title by state.currentTitle.collectAsState()
|
||||
val convId by state.currentConversationId.collectAsState()
|
||||
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||
val titleStr = title ?: "(нет диалога)"
|
||||
Text(
|
||||
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryPanel(state: AppState) {
|
||||
val messages by state.messages.collectAsState()
|
||||
val scroll by state.historyScroll.collectAsState()
|
||||
val rendered = if (messages.isEmpty()) {
|
||||
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||
} else {
|
||||
messages.joinToString("") { renderMessage(it) }
|
||||
}
|
||||
Text(value = rendered)
|
||||
}
|
||||
|
||||
private fun renderMessage(m: TuiMessage): String = when (m) {
|
||||
is TuiMessage.System -> " ── ${m.text}\n"
|
||||
is TuiMessage.User -> " > ${m.text}\n"
|
||||
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputLine(state: AppState) {
|
||||
val text by state.input.collectAsState()
|
||||
val cursor by state.cursor.collectAsState()
|
||||
val streaming by state.streaming.collectAsState()
|
||||
val cursorPos = cursor.coerceIn(0, text.length)
|
||||
val before = text.substring(0, cursorPos)
|
||||
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||
val prompt = if (streaming) " ⋯" else " >"
|
||||
|
||||
Text(
|
||||
value = "$prompt $before|$cursorChar|${after}",
|
||||
modifier = Modifier
|
||||
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||
.drawBehind {
|
||||
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||
state.input.let { /* touch */ }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||
if (ev.alt || ev.ctrl) return false
|
||||
return when (ev.key) {
|
||||
"Enter" -> state.submitInput() != null
|
||||
"Backspace" -> { state.inputBackspace(); true }
|
||||
"Delete" -> { state.inputDelete(); true }
|
||||
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||
"Home" -> { state.inputCursorHome(); true }
|
||||
"End" -> { state.inputCursorEnd(); true }
|
||||
else -> {
|
||||
val s = ev.key
|
||||
if (s.length == 1) { state.inputInsert(s); true }
|
||||
else false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(state: AppState, showHelp: Boolean) {
|
||||
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||
Text(value = hint, textStyle = TextStyle.Italic)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpOverlay() {
|
||||
Column(modifier = Modifier) {
|
||||
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||
Text(value = " ← / → курсор в input")
|
||||
Text(value = " Enter отправить сообщение")
|
||||
Text(value = " Backspace / Del удалить символ")
|
||||
Text(value = " Esc очистить input")
|
||||
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import kotlin.time.Instant
|
||||
* (см. samples/snake в репо Mosaic).
|
||||
*/
|
||||
internal class AppState(val config: TuiConfig) {
|
||||
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
|
||||
private var backend: TuiBackend? = null
|
||||
fun attachBackend(b: TuiBackend) { backend = b }
|
||||
|
||||
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||
private val _focusIndex = MutableStateFlow(0)
|
||||
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||
@@ -101,9 +105,28 @@ internal class AppState(val config: TuiConfig) {
|
||||
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||
inputClear()
|
||||
_streaming.value = true
|
||||
backend?.onUserMessage(text)
|
||||
return text
|
||||
}
|
||||
|
||||
fun setStreaming(v: Boolean) { _streaming.value = v }
|
||||
|
||||
fun setConversation(id: String, title: String?) {
|
||||
_currentConversationId.value = id
|
||||
_currentTitle.value = title
|
||||
_messages.value = emptyList()
|
||||
_historyScroll.value = 0
|
||||
_streaming.value = false
|
||||
}
|
||||
|
||||
fun postToolCall(toolName: String, title: String?, args: String) {
|
||||
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
|
||||
}
|
||||
|
||||
fun postToolResult(toolName: String, result: String) {
|
||||
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
|
||||
}
|
||||
|
||||
fun appendAssistant(chunk: String) {
|
||||
val list = _messages.value.toMutableList()
|
||||
val last = list.lastOrNull()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Точка входа TUI-клиента agentik.
|
||||
@@ -9,14 +15,62 @@ import kotlinx.coroutines.runBlocking
|
||||
* 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 {
|
||||
val cfg = parseCliArgs(args) ?: run {
|
||||
printUsage()
|
||||
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?
|
||||
|
||||
/**
|
||||
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
|
||||
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
|
||||
*/
|
||||
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
|
||||
|
||||
private fun printUsage() {
|
||||
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||
@@ -86,11 +146,15 @@ private fun printUsage() {
|
||||
--no-history не сохранять состояние
|
||||
--help, -h эта справка
|
||||
|
||||
Переменные среды:
|
||||
AGENTIK_SERVER базовый URL (эквивалент --server)
|
||||
USER / USERNAME используется в id клиента по умолчанию
|
||||
|
||||
В UI:
|
||||
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||
← / → двинуть курсор в инпуте
|
||||
Enter отправить сообщение
|
||||
Enter отправить сообщение (создаст новый диалог, если их нет)
|
||||
Ctrl-C / Ctrl-D выйти
|
||||
F1 показать подсказки по горячим клавишам
|
||||
""".trimIndent())
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import com.jakewharton.mosaic.runMosaicBlocking
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
|
||||
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
|
||||
* его coroutine-scope и ждёт завершения приложения.
|
||||
*
|
||||
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
|
||||
* одной Compose-композиции и пользуются её [CoroutineScope].
|
||||
*
|
||||
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
|
||||
* стартует на пустом [Agent]-заглушке для smoke-теста.
|
||||
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
|
||||
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
|
||||
*/
|
||||
internal class TuiApp(private val config: TuiConfig) {
|
||||
internal class TuiApp(
|
||||
private val config: TuiConfig,
|
||||
private val agent: Agent,
|
||||
) {
|
||||
fun run() {
|
||||
runMosaicBlocking {
|
||||
val state = remember { AppState(config) }
|
||||
val backend = remember { TuiBackend(state = state, agent = agent) }
|
||||
LaunchedEffect(backend) {
|
||||
backend.start(this)
|
||||
}
|
||||
state.attachBackend(backend)
|
||||
App(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Backend-логика TUI: мост между [Agent] и [AppState].
|
||||
*
|
||||
* Жизненный цикл:
|
||||
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
|
||||
* пост-сообщение "connected to …".
|
||||
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
|
||||
* 3. При [onUserMessage] — если текущего диалога нет, создаём
|
||||
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
|
||||
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
|
||||
*
|
||||
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
|
||||
*/
|
||||
internal class TuiBackend(
|
||||
private val state: AppState,
|
||||
private val agent: Agent,
|
||||
) {
|
||||
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
|
||||
private var current: Conversation? = null
|
||||
|
||||
/** Активная джоба подписки на [Conversation.events]. */
|
||||
private var eventsJob: Job? = null
|
||||
|
||||
/** Последний виденный момент событий — для переподписки при reconnect. */
|
||||
private var lastSeenAt: Instant = Instant.DISTANT_PAST
|
||||
|
||||
/**
|
||||
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
|
||||
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
|
||||
*/
|
||||
fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
state.postSystem("подключено к ${state.config.server}")
|
||||
scope.launch {
|
||||
try {
|
||||
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||
// штатная отмена при закрытии UI
|
||||
} catch (e: Exception) {
|
||||
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
/**
|
||||
* Обработка пользовательского сообщения, отправленного из input.
|
||||
*
|
||||
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
|
||||
* события конкретного диалога стартует в [ensureConversation].
|
||||
*/
|
||||
fun onUserMessage(text: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val conv = ensureConversation()
|
||||
conv.send(listOf(Content.Text(text)))
|
||||
} catch (e: Exception) {
|
||||
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
|
||||
state.setStreaming(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
|
||||
*/
|
||||
private suspend fun ensureConversation(): Conversation {
|
||||
current?.let { return it }
|
||||
val conv = agent.createConversation(temp = false)
|
||||
state.setConversation(id = conv.id, title = conv.title)
|
||||
subscribeEvents(conv, Instant.DISTANT_PAST)
|
||||
current = conv
|
||||
return conv
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
||||
*/
|
||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||
eventsJob?.cancel()
|
||||
eventsJob = scope.launch {
|
||||
conv.events(from).collect { ev -> dispatch(ev) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг [Event] → [AppState] (что показать в TUI).
|
||||
*
|
||||
* - AppendText → дописывает в последний ассистентский чанк
|
||||
* - StartReasoning / StartResponse → новый streaming-чанк
|
||||
* - End → закрывает streaming
|
||||
* - Interrupted → закрывает streaming + системное сообщение
|
||||
* - ToolCall / ToolResult → сообщения в историю
|
||||
* - Error → системное сообщение
|
||||
*/
|
||||
private fun dispatch(ev: Event) {
|
||||
lastSeenAt = ev.date
|
||||
when (ev) {
|
||||
is Event.AppendText -> state.appendAssistant(ev.body)
|
||||
is Event.StartReasoning -> {
|
||||
state.postSystem("… думаю")
|
||||
}
|
||||
is Event.StartResponse -> state.setStreaming(true)
|
||||
is Event.End -> state.finishAssistant()
|
||||
is Event.Interrupted -> {
|
||||
state.finishAssistant()
|
||||
state.postSystem("прервано")
|
||||
}
|
||||
is Event.AppendImage -> {
|
||||
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
|
||||
}
|
||||
is Event.ToolCall -> {
|
||||
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
|
||||
}
|
||||
is Event.ToolResult -> {
|
||||
state.postToolResult(toolName = "", result = ev.result ?: "")
|
||||
}
|
||||
is Event.Error -> {
|
||||
state.setStreaming(false)
|
||||
state.postSystem("ошибка: ${ev.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
|
||||
/**
|
||||
* Нижняя подсказка с текущим набором горячих клавиш.
|
||||
*
|
||||
* При открытом help-оверлее показывает заглушку с указателем «наверху».
|
||||
*/
|
||||
@Composable
|
||||
internal fun Footer(showHelp: Boolean) {
|
||||
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||
Text(value = hint, textStyle = TextStyle.Italic)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
import pw.binom.agentik.tui.AppState
|
||||
|
||||
/**
|
||||
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
|
||||
*
|
||||
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
|
||||
*/
|
||||
@Composable
|
||||
internal fun Header(state: AppState, focusIndex: Int) {
|
||||
val title by state.currentTitle.collectAsState()
|
||||
val convId by state.currentConversationId.collectAsState()
|
||||
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||
val titleStr = title ?: "(нет диалога)"
|
||||
Text(
|
||||
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
|
||||
/**
|
||||
* Полноэкранный оверлей со списком горячих клавиш.
|
||||
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
|
||||
*/
|
||||
@Composable
|
||||
internal fun HelpOverlay() {
|
||||
Column(modifier = Modifier) {
|
||||
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||
Text(value = " ← / → курсор в input")
|
||||
Text(value = " Enter отправить сообщение")
|
||||
Text(value = " Backspace / Del удалить символ")
|
||||
Text(value = " Esc очистить input")
|
||||
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import pw.binom.agentik.tui.AppState
|
||||
import pw.binom.agentik.tui.TuiMessage
|
||||
|
||||
/**
|
||||
* Прокручиваемый (через клавиатуру) лог диалога.
|
||||
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
|
||||
* При пустом списке показывается подсказка.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HistoryPanel(state: AppState) {
|
||||
val messages by state.messages.collectAsState()
|
||||
val rendered = if (messages.isEmpty()) {
|
||||
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||
} else {
|
||||
messages.joinToString("") { renderMessage(it) }
|
||||
}
|
||||
Text(value = rendered)
|
||||
}
|
||||
|
||||
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
|
||||
internal fun renderMessage(m: TuiMessage): String = when (m) {
|
||||
is TuiMessage.System -> " ── ${m.text}\n"
|
||||
is TuiMessage.User -> " > ${m.text}\n"
|
||||
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package pw.binom.agentik.tui.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.jakewharton.mosaic.layout.KeyEvent
|
||||
import com.jakewharton.mosaic.layout.drawBehind
|
||||
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import pw.binom.agentik.tui.AppState
|
||||
|
||||
/**
|
||||
* Нижняя строка ввода с курсором.
|
||||
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
|
||||
*
|
||||
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
|
||||
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
|
||||
*
|
||||
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InputLine(state: AppState) {
|
||||
val text by state.input.collectAsState()
|
||||
val cursor by state.cursor.collectAsState()
|
||||
val streaming by state.streaming.collectAsState()
|
||||
val cursorPos = cursor.coerceIn(0, text.length)
|
||||
val before = text.substring(0, cursorPos)
|
||||
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||
val prompt = if (streaming) " ⋯" else " >"
|
||||
|
||||
Text(
|
||||
value = "$prompt $before|$cursorChar|${after}",
|
||||
modifier = Modifier
|
||||
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||
.drawBehind {
|
||||
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||
state.input.let { /* touch */ }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
|
||||
*
|
||||
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
|
||||
* на корневой обработчик ([pw.binom.agentik.tui.App]).
|
||||
*/
|
||||
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||
if (ev.alt || ev.ctrl) return false
|
||||
return when (ev.key) {
|
||||
"Enter" -> state.submitInput() != null
|
||||
"Backspace" -> { state.inputBackspace(); true }
|
||||
"Delete" -> { state.inputDelete(); true }
|
||||
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||
"Home" -> { state.inputCursorHome(); true }
|
||||
"End" -> { state.inputCursorEnd(); true }
|
||||
else -> {
|
||||
val s = ev.key
|
||||
if (s.length == 1) { state.inputInsert(s); true }
|
||||
else false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
|
||||
* вызвали [createConversation], и отдаёт заранее сконструированные
|
||||
* [FakeConversation].
|
||||
*/
|
||||
internal class FakeAgent(
|
||||
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
|
||||
) : Agent {
|
||||
override val id: String = "fake"
|
||||
var createCount: Int = 0
|
||||
private set
|
||||
val conversations = mutableListOf<FakeConversation>()
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
createCount++
|
||||
val c = conversationFactory()
|
||||
conversations += c
|
||||
return c
|
||||
}
|
||||
|
||||
override suspend fun getConversation(id: String): Conversation? =
|
||||
conversations.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun deleteConversation(id: String): Boolean =
|
||||
conversations.removeAll { it.id == id }
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||
conversations.toList()
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||
}
|
||||
|
||||
/**
|
||||
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
|
||||
* [Event] через общий [MutableSharedFlow]. Используется в тестах
|
||||
* [TuiBackend] для проверки маршрутизации событий в UI.
|
||||
*/
|
||||
internal class FakeConversation(
|
||||
override val id: String = "fake-conv",
|
||||
override val title: String? = null,
|
||||
) : Conversation {
|
||||
override val isSupportImageInput: Boolean = false
|
||||
override val isSupportImageOutput: Boolean = false
|
||||
override val isTemporal: Boolean = false
|
||||
override val updatedAt: Instant = Instant.DISTANT_PAST
|
||||
|
||||
val sent = mutableListOf<List<Content>>()
|
||||
val sentContexts = mutableListOf<MessageContext?>()
|
||||
var closed: Boolean = false
|
||||
private set
|
||||
var interrupted: Boolean = false
|
||||
private set
|
||||
|
||||
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
|
||||
fun emit(e: Event) { eventsFlow.tryEmit(e) }
|
||||
|
||||
override suspend fun rename(title: String) = Unit
|
||||
|
||||
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||
sent += content
|
||||
sentContexts += context
|
||||
}
|
||||
|
||||
override suspend fun interrupt() { interrupted = true }
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = eventsFlow
|
||||
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
|
||||
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package pw.binom.agentik.tui
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
|
||||
* для передачи в `start` — фоновые подписки должны жить параллельно с
|
||||
* телом теста и автоматически отменяться по его завершении. Иначе
|
||||
* бесконечный collect на `agent.events()` завешивает runTest на 60s
|
||||
* `UncompletedCoroutinesError`.
|
||||
*
|
||||
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
|
||||
* потому что `backgroundScope` использует свой диспетчер, который не
|
||||
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
|
||||
* те задачи, что готовы к запуску сейчас.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TuiBackendTest {
|
||||
|
||||
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
|
||||
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
|
||||
|
||||
@Test
|
||||
fun `start posts connected system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains(cfg.server) },
|
||||
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("привет")
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, agent.createCount, "должен быть один createConversation")
|
||||
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
|
||||
// temp=false — обычный (не временный) диалог: персистится на сервере
|
||||
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
|
||||
// state знает id и title нового диалога
|
||||
assertEquals("fake-conv", state.currentConversationId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second onUserMessage reuses same conversation`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent()
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("раз")
|
||||
runCurrent()
|
||||
backend.onUserMessage("два")
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
|
||||
assertEquals(2, agent.conversations.first().sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AppendText appends to current assistant streaming chunk`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "Привет"))
|
||||
conv.emit(Event.AppendText(now, ", мир"))
|
||||
runCurrent()
|
||||
|
||||
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
|
||||
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
|
||||
assertEquals("Привет, мир", assistantMsgs.single().text)
|
||||
assertTrue(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `End event finalizes assistant and stops streaming`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "ответ"))
|
||||
conv.emit(Event.End(now))
|
||||
runCurrent()
|
||||
|
||||
val last = state.messages.value.last()
|
||||
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
|
||||
assertEquals("ответ", (last as TuiMessage.Assistant).text)
|
||||
assertFalse(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Interrupted event clears streaming and posts system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.AppendText(now, "часть ответа"))
|
||||
conv.emit(Event.Interrupted(now))
|
||||
runCurrent()
|
||||
|
||||
assertFalse(state.streaming.value)
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains("прервано") },
|
||||
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
||||
runCurrent()
|
||||
|
||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
|
||||
assertEquals(1, toolMsgs.size)
|
||||
assertEquals("echo", toolMsgs.single().toolName)
|
||||
assertEquals("""{"x":1}""", toolMsgs.single().args)
|
||||
assertEquals(1, resultMsgs.size)
|
||||
assertEquals("ok", resultMsgs.single().result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Error event posts system message and clears streaming`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
|
||||
conv.emit(Event.Error(date = now, message = "boom"))
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
|
||||
assertFalse(state.streaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(
|
||||
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
|
||||
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
|
||||
)
|
||||
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StartReasoning posts thinking system message`() = runTest {
|
||||
val cfg = fixtureConfig()
|
||||
val state = AppState(cfg)
|
||||
val conv = FakeConversation()
|
||||
val agent = FakeAgent(conversationFactory = { conv })
|
||||
val backend = TuiBackend(state = state, agent = agent)
|
||||
backend.start(backgroundScope)
|
||||
runCurrent()
|
||||
|
||||
backend.onUserMessage("hi")
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.StartReasoning(now))
|
||||
runCurrent()
|
||||
|
||||
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
|
||||
assertTrue(sysMsgs.any { it.text.contains("думаю") })
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<List<Content>>.flattenText(): List<String> =
|
||||
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
|
||||
@@ -4,11 +4,9 @@ import pw.binom.agentik.client.AgentikAgent
|
||||
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)
|
||||
|
||||
/**
|
||||
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
|
||||
*/
|
||||
internal fun jvmCreateAgent(baseUrl: String, id: String): Agent = AgentikAgent(id = id, baseUrl = baseUrl)
|
||||
internal actual fun platformCreateAgent(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 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)")
|
||||
|
||||
+6
-4
@@ -11,14 +11,16 @@ group = "pw.binom.agentik"
|
||||
// прочитать через rootProject.extra["projectVersion"].
|
||||
|
||||
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
|
||||
// fallback в gradle.properties ("version=0.1.0"). Без версии maven-publish падает
|
||||
// с "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
||||
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
|
||||
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
|
||||
// gradle.properties). Без версии maven-publish падает с
|
||||
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
|
||||
// это известный gotcha: subprojects читают rootProject.version ДО того, как
|
||||
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
|
||||
// eagerly, и subprojects получают готовую строку.
|
||||
val projectVersion: String = providers.gradleProperty("version")
|
||||
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
|
||||
.getOrElse("0.1.0")
|
||||
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
|
||||
version = projectVersion
|
||||
extra["projectVersion"] = projectVersion
|
||||
|
||||
@@ -49,7 +51,7 @@ val moduleDescriptions: Map<String, String> = mapOf(
|
||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
||||
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||
"agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.",
|
||||
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
|
||||
)
|
||||
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
|
||||
|
||||
+75
-41
@@ -1,67 +1,101 @@
|
||||
# :client — `pw.binom.agentik.client`
|
||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
||||
|
||||
**Ktor-клиент, превращающий HTTP-фасад `:server` обратно в `Agent`/`Conversation` из `:proto`.**
|
||||
Подходит для JVM-приложений (CLI, desktop, integration-тесты).
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
||||
интерфейсы `:proto`:
|
||||
|
||||
После того, как `:server` выставляет агента по HTTP, встаёт задача: дать
|
||||
вызывающей стороне **тот же интерфейс**, что был на сервере — а не отдельный
|
||||
REST-клиент с хендшейкингом SSE, парсингом полей, ручной постраничной подгрузкой.
|
||||
`:client` — обёртка: `AgentikAgent(baseUrl).createConversation()` возвращает
|
||||
`Conversation`, идентичный серверному, а вызовы `send/getMessages/events`
|
||||
прозрачно ездят по HTTP.
|
||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
||||
- `AgentClient` — список и lifecycle диалогов.
|
||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
||||
`getMessages()`, `rename()`, `close()`.
|
||||
- Внутренний парсер SSE → `Flow<Event>`.
|
||||
|
||||
## Использование
|
||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
||||
JVM, iOS, macOS, Linux, Windows.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:agentik-cli` — REPL.
|
||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:client:0.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// ваш код:
|
||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
||||
val conv = agent.createConversation(title = "test")
|
||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
||||
when (event) {
|
||||
is Event.AppendText -> print(event.body)
|
||||
is Event.End -> println("\n--- end ---")
|
||||
is Event.Error -> error("agent error: ${event.message}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
conv.send(listOf(Content.Text("Привет. Сколько будет 2+2?")))
|
||||
// ... события стримятся в collect выше
|
||||
conv.close()
|
||||
```
|
||||
|
||||
Для фоновой подписки (reconnect-safe):
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
||||
|
||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
||||
|
||||
## Примеры API
|
||||
|
||||
```kotlin
|
||||
// Долгая живая подписка на события диалога.
|
||||
conv.events(after = lastSeen).collect { ev ->
|
||||
if (ev is Event.End) lastSeen = ev.date
|
||||
// список диалогов
|
||||
agent.getConversations().collect { println(it.id to it.title) }
|
||||
|
||||
// live-подписка на события отдельного диалога
|
||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
||||
|
||||
// прерывание текущего хода
|
||||
conversation.interrupt()
|
||||
|
||||
// история
|
||||
conversation.getMessages(offset = 0).collect { msg ->
|
||||
when (msg) {
|
||||
is Message.UserMessage -> println("user: ${msg.content}")
|
||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Подключение
|
||||
## Тесты
|
||||
|
||||
```kotlin
|
||||
implementation("pw.binom.agentik:client:$version")
|
||||
// Транзитивно тянет :proto + ktor-client-core/cio/... + kotlinx-serialization.
|
||||
```
|
||||
./gradlew :client:jvmTest
|
||||
```
|
||||
|
||||
## SSE-парсер
|
||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
||||
401/404.
|
||||
|
||||
Внутри — самописный парсер SSE (режет поток на `data:` строки, буферизует
|
||||
частичные, переживает keep-alive-комментарии). Зависимости — `ktor-client-cio`
|
||||
по умолчанию; если нужен другой engine — подмените через `AgentikAgent(engineFactory = …)`.
|
||||
## Чего здесь НЕТ
|
||||
|
||||
## Где смотреть версии
|
||||
- Никакого LLM-кода. Это просто клиент.
|
||||
- Никакого persistent state. История хранится у сервера, клиент её
|
||||
запрашивает через `getMessages` или подписывается через `events`.
|
||||
|
||||
- `:client` синхронизирован с `:proto`/`server` — `version` из `gradle.properties`
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
## Текущий статус
|
||||
|
||||
## Сборка
|
||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
||||
но клиент совместим с любым сервером, который держит wire-контракт
|
||||
`:server`.
|
||||
|
||||
```bash
|
||||
./gradlew :client:build
|
||||
```
|
||||
## Известное ограничение
|
||||
|
||||
JVM-only (ktor-client-engine-cio — JVM).
|
||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||
|
||||
+34
-9
@@ -1,18 +1,26 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
jvmToolchain(21)
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proto"))
|
||||
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
|
||||
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
|
||||
// без iOS. См. agentik-cli/build.gradle.kts.
|
||||
jvm()
|
||||
listOf(
|
||||
macosX64(),
|
||||
macosArm64(),
|
||||
linuxX64(),
|
||||
linuxArm64(),
|
||||
mingwX64(),
|
||||
)
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":proto"))
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
@@ -23,3 +31,20 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.test.host)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.sse)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation("junit:junit:4.13.2")
|
||||
}
|
||||
}
|
||||
|
||||
// :client — это библиотека, не executable. Native-бинари объявляются
|
||||
// в :agentik-cli (он зависит от :client и реально предоставляет main).
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
@@ -66,7 +67,8 @@ internal class AgentClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||
val response = httpClient.get("$agentUrl/events?after=$after")
|
||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
@@ -76,3 +78,4 @@ internal class AgentClient(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -1,9 +1,6 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
@@ -24,20 +21,27 @@ import pw.binom.agentik.proto.Agent
|
||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
||||
* конфига).
|
||||
*
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
|
||||
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
|
||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
||||
*/
|
||||
fun AgentikAgent(
|
||||
id: String,
|
||||
baseUrl: String,
|
||||
httpClient: HttpClient = defaultAgentikHttpClient(),
|
||||
token: String? = null,
|
||||
httpClient: HttpClient = defaultAgentikHttpClient(token),
|
||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
||||
|
||||
/**
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
|
||||
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
|
||||
* (см. [readSse]) живёт в общем коде и плагина не требует.
|
||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
||||
*
|
||||
* **Платформы:**
|
||||
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
||||
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
||||
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
||||
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
||||
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
||||
*
|
||||
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
||||
* Реализация — в [HttpClientFactory.kt].
|
||||
*/
|
||||
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,7 @@ import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
@@ -72,7 +73,11 @@ internal class ConversationClient(
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = flow {
|
||||
val response = httpClient.get("$convUrl/events?after=$after")
|
||||
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
||||
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
||||
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
||||
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
@@ -81,6 +86,7 @@ internal class ConversationClient(
|
||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||
httpClient.get("$convUrl/messages") {
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
|
||||
/**
|
||||
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
||||
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
||||
*
|
||||
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
||||
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
||||
*
|
||||
* При заданном [token] на ВСЕ запросы клиента навешивается
|
||||
* `Authorization: Bearer <token>` через плагин [DefaultRequest]. Это накрывает
|
||||
* все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте,
|
||||
* а не в отдельных запросах.
|
||||
*/
|
||||
fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) {
|
||||
engine { requestTimeout = 0 }
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
if (token != null) {
|
||||
install(DefaultRequest) {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
|
||||
/**
|
||||
* Отключает request/connect/socket-таймауты для конкретного запроса через
|
||||
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
|
||||
*
|
||||
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
|
||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||
*
|
||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||
* установленном capability мутирует его поля через `?:`, так что шаренный
|
||||
* инстанс мог бы утечь между запросами.
|
||||
*/
|
||||
internal fun HttpRequestBuilder.noSseReadTimeout() {
|
||||
setCapability(
|
||||
HttpTimeoutCapability,
|
||||
HttpTimeoutConfig(
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает
|
||||
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||
* без токена — заголовок не отправляется.
|
||||
*
|
||||
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
|
||||
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
|
||||
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
|
||||
* минимально, контракт тот же.
|
||||
*/
|
||||
class BearerHeaderTest {
|
||||
|
||||
private val TestBearer = createRouteScopedPlugin(
|
||||
name = "TestBearer",
|
||||
createConfiguration = ::BearerCfg,
|
||||
) {
|
||||
val expected = pluginConfig.token
|
||||
onCall { call ->
|
||||
if (expected == null) return@onCall
|
||||
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BearerCfg {
|
||||
var token: String? = null
|
||||
}
|
||||
|
||||
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||
val server = embeddedServer(ServerCIO, port = 0) {
|
||||
routing {
|
||||
route("/agentik") {
|
||||
install(TestBearer) { token = "secret" }
|
||||
get("/conversations") {
|
||||
call.respondText("[]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
return server to port
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient("secret")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
assertEquals("[]", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient(null)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
||||
val (server, port) = startServer()
|
||||
try {
|
||||
val client = defaultAgentikHttpClient("wrong")
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondBytesWriter
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.writeStringUtf8
|
||||
import io.ktor.utils.io.readUTF8Line
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.net.ServerSocket
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
|
||||
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
|
||||
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
|
||||
* перед каждым read-стримом.
|
||||
*
|
||||
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
|
||||
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
|
||||
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
|
||||
* сообщение доходит.
|
||||
*
|
||||
* Читаем строки пока не найдём "data: done" или пока не сработает
|
||||
* [withTimeout] (18 с — запас над server delay 20 с).
|
||||
*/
|
||||
class SseTimeoutTest {
|
||||
|
||||
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
|
||||
|
||||
@Test
|
||||
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
|
||||
val port = freePort()
|
||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||
routing {
|
||||
get("/sse") {
|
||||
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||
writeStringUtf8("data: hello\n\n")
|
||||
flush()
|
||||
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
|
||||
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
|
||||
delay(17_000)
|
||||
writeStringUtf8("data: done\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val received = mutableListOf<String>()
|
||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||
header("Accept", "text/event-stream")
|
||||
noSseReadTimeout()
|
||||
}.execute { resp ->
|
||||
val ch = resp.bodyAsChannel()
|
||||
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
|
||||
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
|
||||
val deadline = 19_000L
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < deadline) {
|
||||
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
|
||||
if (line.startsWith("data: ")) {
|
||||
received.add(line)
|
||||
}
|
||||
if (line == "data: done") break
|
||||
}
|
||||
}
|
||||
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
|
||||
assertTrue(
|
||||
received.contains("data: done"),
|
||||
"должно получить done (SSE read не должен падать на 15 с): $received",
|
||||
)
|
||||
assertFalse(
|
||||
received.any { it == "<timeout>" },
|
||||
"SSE read упал в timeout (capability не сработал): $received",
|
||||
)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
|
||||
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
|
||||
* Сервер держит stream 17 с; если клиент не выставил capability —
|
||||
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
|
||||
* дожидаясь "done".
|
||||
*/
|
||||
@Test
|
||||
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
|
||||
val port = freePort()
|
||||
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
|
||||
routing {
|
||||
get("/sse") {
|
||||
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
|
||||
writeStringUtf8("data: hello\n\n")
|
||||
flush()
|
||||
delay(17_000)
|
||||
writeStringUtf8("data: done\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
client.prepareGet("http://127.0.0.1:$port/sse") {
|
||||
header("Accept", "text/event-stream")
|
||||
// НАМЕРЕННО без noSseReadTimeout.
|
||||
}.execute { resp ->
|
||||
val ch = resp.bodyAsChannel()
|
||||
// Читаем строки, пока не придёт "data: done" — без capability
|
||||
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
|
||||
while (true) {
|
||||
val line = ch.readUTF8Line() ?: break
|
||||
if (line == "data: done") break
|
||||
}
|
||||
}
|
||||
fail("без capability клиент должен словить HttpRequestTimeoutException")
|
||||
} catch (e: HttpRequestTimeoutException) {
|
||||
val elapsed = System.currentTimeMillis() - start
|
||||
assertTrue(
|
||||
elapsed in 14_000..17_000,
|
||||
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# 01 — Слои модулей (целевое состояние)
|
||||
|
||||
Целевая модульная структура agentik. Снизу вверх:
|
||||
**приложения → runtime → домен → абстракции → платформенные impl**.
|
||||
|
||||

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

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

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

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

|
||||
|
||||
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
|
||||
|
||||
```plantuml
|
||||
@startuml android-agent-stack
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
title Android Agent Stack — что меняется vs Standalone
|
||||
|
||||
' --- Android side ---
|
||||
package "Android Application" {
|
||||
[MainActivity\n(Compose)] as Activity
|
||||
[AndroidAgentRunner\n(workmanager / service)] as Runner
|
||||
[AndroidAgentBuilder] as AndroidBuilder
|
||||
}
|
||||
|
||||
package "Android-specific impls" {
|
||||
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
|
||||
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
|
||||
[LitertAndroid\n(NNAPI delegate)] as LitertA
|
||||
[SoulFileAndroid\n(context.filesDir)] as SoulA
|
||||
[McpRegistry\nstdio: ProcessBuilder] as McpA
|
||||
}
|
||||
|
||||
' --- Shared (KMP) ---
|
||||
package "Agent Runtime (shared)" {
|
||||
[AgentCore\nBaseAgent] as AgentCore
|
||||
[AgentBuilder] as Builder
|
||||
}
|
||||
|
||||
package "Domain (shared)" {
|
||||
[LlmTools\ncommonMain] as LlmT
|
||||
[BackgroundEvents\ncommonMain] as Ev
|
||||
[McpBridge\njvmMain] as McpB
|
||||
[Skills\ncommonMain] as Skills
|
||||
}
|
||||
|
||||
package "Memory (shared impl)" {
|
||||
[MemoryMd\n(commonMain)] as MemMd
|
||||
[MemoryApi\ninterfaces] as MemApi
|
||||
}
|
||||
|
||||
' --- Зависимости ---
|
||||
Activity --> Runner
|
||||
Runner --> AndroidBuilder
|
||||
AndroidBuilder --> AgentCore
|
||||
|
||||
AndroidBuilder --> StorageA
|
||||
AndroidBuilder --> MemVecA
|
||||
AndroidBuilder --> LitertA
|
||||
AndroidBuilder --> SoulA
|
||||
AndroidBuilder --> McpA
|
||||
|
||||
AgentCore --> LlmT
|
||||
AgentCore --> Ev
|
||||
AgentCore --> McpB
|
||||
AgentCore --> Skills
|
||||
AgentCore --> MemMd
|
||||
|
||||
' --- Главные отличия от Standalone ---
|
||||
note right of LitertA
|
||||
On-device inference.
|
||||
LiteRT с NNAPI delegate →
|
||||
работает на CPU/GPU/NPU
|
||||
прямо на устройстве, без сети.
|
||||
|
||||
vs Standalone: HTTP-only
|
||||
(OpenAI-compatible).
|
||||
end note
|
||||
|
||||
note right of StorageA
|
||||
android.database.sqlite
|
||||
через Room или сырой API.
|
||||
|
||||
vs Standalone: JDBC +
|
||||
Sqlite-JDBC driver
|
||||
(только JVM).
|
||||
end note
|
||||
|
||||
note right of MemVecA
|
||||
JVector JVM-only. На Android
|
||||
нужна альтернатива —
|
||||
ONNX Runtime + какой-нибудь
|
||||
ANN (Annoy/HNSW).
|
||||
|
||||
Или пока без vector memory,
|
||||
только MemoryMd.
|
||||
end note
|
||||
|
||||
note right of McpA
|
||||
MCP через ProcessBuilder
|
||||
на Android работает, но
|
||||
subprocess lifecycle
|
||||
сложнее (foreground service
|
||||
нужен для долгого subprocess).
|
||||
end note
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Что общего с `:standalone`
|
||||
|
||||
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
|
||||
|
||||
## Что другое
|
||||
|
||||
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|
||||
|---|---|---|---|
|
||||
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
|
||||
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
|
||||
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
|
||||
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
|
||||
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
|
||||
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
|
||||
|
||||
## Минимальный Android agent (v1)
|
||||
|
||||
Если не нужны все фичи сразу — минимум:
|
||||
|
||||
```kotlin
|
||||
val agent = androidAgentBuilder(context) {
|
||||
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
|
||||
storage(SqliteStorage.android(context, "agent.db"))
|
||||
memory(MemoryMd.root(context.filesDir.resolve("memory")))
|
||||
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
|
||||
background {
|
||||
// OnClosing + OnCompaction работают так же как на JVM
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
|
||||
|
||||
## Foreground service для MCP
|
||||
|
||||
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
|
||||
|
||||
```kotlin
|
||||
class McpForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
val proc = ProcessBuilder(command, args).start()
|
||||
// ... route stdio to McpLiteToolAdapter ...
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пока можно без этого (только если MCP нужен на Android).
|
||||
|
||||
## Текущее состояние vs целевое
|
||||
|
||||
✅ KMP-ready:
|
||||
- `:llm-tools` (commonMain, платформо-агностик)
|
||||
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
|
||||
- `:skills` (commonMain)
|
||||
- `:memory-md` (commonMain)
|
||||
- `:proto` (commonMain)
|
||||
|
||||
⏳ Не существует:
|
||||
- `:storage-sqlite-android`
|
||||
- `:memory-vector-android`
|
||||
- `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `:agent-core` (выделить BaseAgent + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,64 @@
|
||||
# agentik — диаграммы архитектуры
|
||||
|
||||
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
|
||||
|
||||
## Файлы
|
||||
|
||||
Каждый `.md` содержит:
|
||||
- Краткое описание (что показывает)
|
||||
- **Пред-рендеренный SVG** (``) — гарантированно показывается **везде**
|
||||
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
|
||||
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
|
||||
|
||||
| Файл | Что показывает |
|
||||
|---|---|
|
||||
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
|
||||
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
|
||||
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
|
||||
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
|
||||
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
|
||||
|
||||
## Почему SVG + PlantUML source
|
||||
|
||||
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
|
||||
|
||||
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
|
||||
|
||||
## Как редактировать диаграмму
|
||||
|
||||
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
|
||||
2. Ре-рендеришь SVG:
|
||||
```bash
|
||||
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
|
||||
cp docs/diagrams/*.md /tmp/plantuml-work/
|
||||
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
|
||||
cp /tmp/plantuml-work/*.svg docs/diagrams/
|
||||
```
|
||||
3. Проверяешь что SVG обновился:
|
||||
```bash
|
||||
ls -la docs/diagrams/*.svg
|
||||
```
|
||||
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
|
||||
|
||||
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий код движется в эту сторону:
|
||||
- `:llm-tools` extracted ✅
|
||||
- `:mcp-bridge` extracted ✅
|
||||
- `BackgroundScheduler` стал event-driven ✅
|
||||
- `ConversationLoop` стал отдельным компонентом ✅
|
||||
|
||||
Не сделано (см. детали в каждом .md):
|
||||
- `:agent-core` (выделить `BaseAgent` + builder)
|
||||
- `:background-events` (выделить events + scheduler)
|
||||
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
|
||||
- `:agentik-android` (само приложение)
|
||||
- `MentionDetector` interface + adapters для multi-user chat
|
||||
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
|
||||
|
||||
Подробнее:
|
||||
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
|
||||
- `MEMORY-DESIGN.md` — детали memory архитектуры
|
||||
- `STANDALONE.md` — текущий standalone
|
||||
+6
-2
@@ -1,5 +1,9 @@
|
||||
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD.
|
||||
version=0.1.0
|
||||
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
|
||||
# Имя ключа специально НЕ 'version' — иначе Gradle-мерж gradle.properties и
|
||||
# -Pversion= возьмёт default из gradle.properties. Передавай через CICD:
|
||||
# ./gradlew ... -Pversion=$(git describe --tags)
|
||||
# см. .gitea/workflows/release.yml (использует -Pversion=$GITHUB_REF_NAME).
|
||||
agentik.version.default=0.1.0-SNAPSHOT
|
||||
|
||||
# KMP jvm target uses JDK 21 for both compilation and toolchain.
|
||||
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
|
||||
|
||||
@@ -13,8 +13,9 @@ jvector = "3.0.6"
|
||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||
kotlin-logging = "3.0.5"
|
||||
logback = "1.5.18"
|
||||
jline = "3.30.0"
|
||||
mosaic = "0.18.0"
|
||||
clikt = "5.0.3"
|
||||
kotlinx-cli = "0.3.6"
|
||||
|
||||
[plugins]
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
@@ -54,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||
@@ -61,8 +63,12 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
|
||||
# --- Model Context Protocol (MCP) ---
|
||||
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||
|
||||
# --- CLI: JLine (readline для JVM-таргета) ---
|
||||
jline = { module = "org.jline:jline", version.ref = "jline" }
|
||||
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
|
||||
# https://ajalt.github.io/clikt/
|
||||
# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module
|
||||
# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...).
|
||||
clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" }
|
||||
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" }
|
||||
|
||||
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||
# https://github.com/JakeWharton/mosaic
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
//
|
||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
||||
// доступные JVM target'у.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":memory-api"))
|
||||
api(project(":storage-core"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,7 +27,7 @@ import kotlin.time.Clock
|
||||
*/
|
||||
class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
private val maxTurns: Int = 6,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val clock: Clock = Clock.System,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
/**
|
||||
* Минимальный парсер JSON-ответа от [LlmReflector].
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent.memory
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
|
||||
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
|
||||
// в набор [LiteTool]-адаптеров.
|
||||
//
|
||||
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
|
||||
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
|
||||
// или :agentik-tui когда те снова включатся.
|
||||
//
|
||||
// Зависимости:
|
||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
||||
// - litert.api для LiteTool контракта
|
||||
// - MCP SDK (JVM-only)
|
||||
// - Ktor client (для StreamableHttpClientTransport)
|
||||
// - kotlinx-serialization для парсинга конфига
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":agent-toolsets"))
|
||||
|
||||
api(libs.litert.api)
|
||||
|
||||
implementation(libs.mcp.sdk.client)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlin.logging)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.mcp
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
import mu.KotlinLogging
|
||||
|
||||
@@ -30,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.standalone.agent.NamedTool
|
||||
import pw.binom.litert.LiteTool
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Реестр подключённых MCP-серверов.
|
||||
+56
-53
@@ -1,77 +1,80 @@
|
||||
# :memory-api — `pw.binom.agentik.memory`
|
||||
# `:memory-api` — контракт долговременной памяти (KMP, jvm + native)
|
||||
|
||||
**Интерфейсы долговременной памяти агента: `MemoryStore`, `MemoryNote`,
|
||||
`MemoryCategory`, `MemoryPrefetcher`, `MemoryReviewer`, `MemoryTools`.**
|
||||
Без зависимостей от конкретного хранилища.
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Интерфейсы долговременной памяти агента:
|
||||
|
||||
LLM не помнит между сессиями. Чтобы агент становился **умнее с каждым
|
||||
диалогом**, нужна долговременная память: факты о пользователе, мире,
|
||||
предпочтениях, плюс механизм извлечения (reviewer) и подмешивания (prefetcher)
|
||||
в контекст. Бэкенды памяти бывают разные (md-файлы, векторный ANN, sqlite,
|
||||
KV-store), и `:standalone` не должен быть привязан ни к одному из них.
|
||||
- `MemoryStore` — append-only журнал `MemoryNote(id, content, createdAt)`.
|
||||
- `MemoryCategory` — discriminator (`USER`, `WORLD`, `PREFERENCE`,
|
||||
кастомные).
|
||||
- `MemoryNote` — структурная единица памяти; immutable.
|
||||
- Прелоадер / ревьювер по контракту, не по реализации.
|
||||
|
||||
`:memory-api` определяет **контракт**: что умеет любая реализация памяти.
|
||||
Конкретные бэкенды — `:memory-md` (Hermes-style §-файлы) и `:memory-vector`
|
||||
(SQLite + JVector + эмбеддинги).
|
||||
Решает: как единая абстракция позволяет иметь одновременно файловую
|
||||
память (`:memory-md`), SQLite + ANN (`:memory-vector`) и тестовую
|
||||
in-memory (в `:standalone/tests`). Агент работает с `MemoryStore`,
|
||||
не с конкретным бэкендом.
|
||||
|
||||
## Что в контракте
|
||||
## Где используется
|
||||
|
||||
- `:memory-md` — Hermes-style `§`-файлы (user.md / world.md /
|
||||
preference.md).
|
||||
- `:memory-vector` — SQLite + JVector + LLM-эмбеддинги.
|
||||
- `:standalone` подключает обе реализации и переключает через
|
||||
`AGENTIK_MEMORY_BACKEND=md|vector|off`.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
interface MemoryStore : AutoCloseable {
|
||||
suspend fun upsert(note: MemoryNote)
|
||||
suspend fun get(id: String): MemoryNote?
|
||||
suspend fun list(category: MemoryCategory?, conversationId: String?, limit, offset): List<MemoryNote>
|
||||
suspend fun search(query: MemorySearchQuery): List<MemorySearchResult>
|
||||
suspend fun delete(id: String): Boolean
|
||||
suspend fun markUsed(ids: List<String>) // бампит lastUsedAt + useCount
|
||||
fun events(): Flow<MemoryStoreEvent> // опционально
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:memory-api:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Артефакт публикуется в `caffeine`.
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-memory-api`.
|
||||
|
||||
## Что в API
|
||||
|
||||
```kotlin
|
||||
interface MemoryStore {
|
||||
suspend fun save(category: MemoryCategory, content: String): MemoryNote
|
||||
suspend fun query(category: MemoryCategory?, q: String, limit: Int = 10): List<MemoryNote>
|
||||
suspend fun all(category: MemoryCategory? = null): List<MemoryNote>
|
||||
}
|
||||
|
||||
enum class MemoryCategory { USER, WORLD, PREFERENCE }
|
||||
enum class MemoryCategory(val path: String) {
|
||||
USER("user"),
|
||||
WORLD("world"),
|
||||
PREFERENCE("preference");
|
||||
}
|
||||
|
||||
data class MemoryNote(
|
||||
val id: String,
|
||||
val conversationId: String?,
|
||||
val category: MemoryCategory,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val source: MemorySource, // AUTO_REVIEW / USER / MANUAL
|
||||
val content: String,
|
||||
val createdAt: Instant,
|
||||
val lastUsedAt: Instant?,
|
||||
val useCount: Int,
|
||||
)
|
||||
```
|
||||
|
||||
`MemoryPrefetcher` — компонент, который **перед** каждым user-ходом выбирает
|
||||
релевантные заметки (через `search()`) и форматирует `[Memory context…]` блок
|
||||
в начало user-сообщения. `MemoryReviewer` — компонент, который **после**
|
||||
assistant-хода извлекает новые факты (через LiteLlm) и вызывает `upsert`.
|
||||
`MemoryTools` — `memory_save` / `memory_recall` / `memory_list` / `memory_delete`,
|
||||
которыми модель может пользоваться явно.
|
||||
## Тесты
|
||||
|
||||
## Подключение
|
||||
|
||||
```kotlin
|
||||
commonMain {
|
||||
implementation("pw.binom.agentik:memory-api:$version")
|
||||
// + выберите реализацию:
|
||||
implementation("pw.binom.agentik:memory-md:$version") // md-файлы
|
||||
// или
|
||||
implementation("pw.binom.agentik:memory-vector:$version") // JVector+SQLite
|
||||
}
|
||||
```
|
||||
./gradlew :memory-api:allTests
|
||||
```
|
||||
|
||||
## Где смотреть версии
|
||||
Контрактные тесты на Kotlin Multiplatform (без jvmTest-специфики).
|
||||
|
||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
## Чего здесь НЕТ
|
||||
|
||||
## Сборка
|
||||
- Никаких конкретных storage — это API. Backend-ы в `:memory-md` и
|
||||
`:memory-vector`.
|
||||
|
||||
```bash
|
||||
./gradlew :memory-api:build
|
||||
```
|
||||
## Текущий статус
|
||||
|
||||
KMP-таргеты — полный набор. Зависимостей нет (только `kotlinx-coroutines-core` для `Flow`).
|
||||
Используется продакшеном. Контракт стабильный.
|
||||
|
||||
+58
-44
@@ -1,63 +1,77 @@
|
||||
# :memory-md — `pw.binom.agentik.memory.md`
|
||||
# `:memory-md` — файловое хранилище памяти (JVM-only)
|
||||
|
||||
**Hermes-style реализация долговременной памяти поверх обычных markdown-файлов.**
|
||||
По одной §-секции на заметку, в файлах `user.md` / `world.md` / `preference.md`.
|
||||
Без внешних зависимостей, без эмбеддингов, без БД.
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Реализация `MemoryStore` поверх обычных файлов в формате [Hermes-style]:
|
||||
|
||||
Минимально работающая память **без инфраструктуры**: открыл текстовый редактор —
|
||||
посмотрел, поправил, удалил. Версионируется в git вместе с проектом, бэкапится
|
||||
как обычные файлы. Полезно для дев-окружения, для одиночных пользователей,
|
||||
для отладки vector-бэкенда.
|
||||
- `~/.agentik/memory/user.md`
|
||||
- `~/.agentik/memory/world.md`
|
||||
- `~/.agentik/memory/preference.md`
|
||||
|
||||
## Формат файла
|
||||
Каждая секция — это `## <heading>` + содержимое. Ревьювер ищет
|
||||
по заголовкам/словам по ключевому совпадению. Префетчер лениво
|
||||
подгружает секции, наиболее вероятно относящиеся к текущему ходу.
|
||||
|
||||
Решает: простой, прозрачный, git-дружелюбный формат памяти.
|
||||
Пользователь может сам `cat ~/.agentik/memory/world.md` и
|
||||
отредактировать.
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подключает вместо `:memory-vector` когда
|
||||
`AGENTIK_MEMORY_BACKEND=md`.
|
||||
- Дефолт, когда ANN-эмбеддинги слишком дороги или не нужны.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("pw.binom.agentik:memory-md:0.1.0")
|
||||
implementation("pw.binom.agentik:memory-api:0.1.0") // контракт
|
||||
}
|
||||
|
||||
val memory: MemoryStore = openMdMemorySystem(Path("~/.agentik/memory"))
|
||||
memory.save(MemoryCategory.USER, "User prefers tasks short.")
|
||||
memory.query(MemoryCategory.USER, "preferences").forEach(::println)
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-memory-md`.
|
||||
|
||||
## Как устроен формат
|
||||
|
||||
```markdown
|
||||
# user.md
|
||||
|
||||
# agentik-note id=a3f1e2b7 created=2026-09-01 uses=3 last=2026-09-12
|
||||
Пользователь предпочитает короткие ответы без эмодзи. Не любит вим.
|
||||
## 2026-09-14T10:00:00Z — first session
|
||||
Имя пользователя — Сережа.
|
||||
Любит короткие ответы.
|
||||
|
||||
# agentik-note id=b9c4d8e1 created=2026-09-10 uses=1 last=2026-09-12
|
||||
Дедлайн релиза agentik — 25 сентября.
|
||||
|
||||
# preference.md
|
||||
|
||||
# agentik-note id=c1d2e3f4 created=2026-09-08 uses=0 last=never
|
||||
Отвечать по-русски, без жаргона.
|
||||
## 2026-09-15T18:20:00Z — task preferences
|
||||
Не присылать пустые репро.
|
||||
```
|
||||
|
||||
Заголовок с метаданными (`# agentik-note id=… created=… uses=… last=…`), пустая
|
||||
строка, markdown-body. Curator (фоновая корутина) переименовывает «протухшие»
|
||||
заметки (`useCount=0` и `last > 90 дней назад`) в `.archived.{ts}`.
|
||||
Каждая запись начинается с заголовка второго уровня и содержит в
|
||||
первой строке заголовка timestamp и короткое название. Так достигается
|
||||
уникальность и читаемость через `cat`.
|
||||
|
||||
## Подключение
|
||||
## Тесты
|
||||
|
||||
```kotlin
|
||||
commonMain {
|
||||
implementation("pw.binom.agentik:memory-md:$version")
|
||||
// транзитивно: :memory-api + kaml (для YAML-сериализации метаданных)
|
||||
}
|
||||
|
||||
// Использование
|
||||
val store = MemoryMdStore.fromDirectory(
|
||||
dir = Path("~/.agentik/memory"),
|
||||
clock = Clock.System,
|
||||
)
|
||||
val search = store.search(MemorySearchQuery(query = "любимый редактор", limit = 5))
|
||||
```
|
||||
./gradlew :memory-md:jvmTest
|
||||
```
|
||||
|
||||
## Где смотреть версии
|
||||
Покрывают: round-trip save/load, фильтрацию по категории,
|
||||
keyword-search, перезапись, конкурентный доступ (файловая блокировка).
|
||||
|
||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
## Чего здесь НЕТ
|
||||
|
||||
## Сборка
|
||||
- Никаких эмбеддингов. Простой keyword-match (простая substring +
|
||||
TF-IDF-эвристика на русских/латинских словах).
|
||||
- Никакого ANN. Для семантического поиска используйте `:memory-vector`.
|
||||
|
||||
```bash
|
||||
./gradlew :memory-md:build
|
||||
```
|
||||
## Текущий статус
|
||||
|
||||
KMP-таргеты — полный набор. Зависимости — `:memory-api` + `kaml` (YAML-парсинг
|
||||
метаданных).
|
||||
Используется продакшеном. Подходит для долговременного "дневникового"
|
||||
хранения.
|
||||
|
||||
+57
-51
@@ -1,72 +1,78 @@
|
||||
# :memory-vector — `pw.binom.agentik.memory.vector`
|
||||
# `:memory-vector` — ANN/JVector/SQLite память с эмбеддингами (JVM-only)
|
||||
|
||||
**Реализация долговременной памяти поверх JVector (ANN-индекс) + SQLite (метаданные) + эмбеддингов.**
|
||||
JVM-only (JVector не публикует KMP-таргеты; на Android ART работает через Java 11
|
||||
scalar fallback).
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Реализация `MemoryStore` поверх SQLite + [JVector](https://github.com/jbellis/jvector)
|
||||
+ LLM-эмбеддинги:
|
||||
|
||||
`:memory-md` хорош для малых объёмов и дев-окружения, но при тысячах заметок
|
||||
keyword-overlap поиск не справляется. Vector-бэкенд считает **эмбеддинги**
|
||||
заметок, складывает в JVector ANN-индекс, ищет по cosine similarity.
|
||||
`recency-re-rank` подмешивает свежесть, чтобы новые факты не тонули в старых.
|
||||
- **Хранение метаданных** — SQLite (notes, timestamps, источник).
|
||||
- **ANN-индекс** — JVector (тот же класс HNSW, что используется в
|
||||
Cassandra DataStax).
|
||||
- **Эмбеддинги** — два backendа:
|
||||
- **HTTP** — POST на любой OpenAI-совместимый `/v1/embeddings`
|
||||
(vLLM, LiteLLM, text-embedding-ada-002, и т.д.).
|
||||
- **SigLIP2** — локальная модель через [text-embedding-kmp](https://git.binom.pw/subochev/text-embedding-kmp)
|
||||
(ONNX Runtime, без сети).
|
||||
|
||||
## Архитектура
|
||||
Решает: семантический поиск по памяти. "Где я рассказывал про
|
||||
CI/CD" находит нужный эпизод, даже если формулировка другая. При
|
||||
этом offline-capable через SigLIP.
|
||||
|
||||
```
|
||||
┌───────────────────────┐
|
||||
user-query ─►│ EmbeddingProvider │ (HTTP /v1/embeddings или SIGLIP2 on-device)
|
||||
└─────────┬─────────────┘
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ MemoryVectorStore │
|
||||
│ ├─ JVector (cosine) │ ◄── ANN-search
|
||||
│ └─ SQLite (мета) │ ◄── заметки + lastUsedAt + useCount
|
||||
└───────────────────────┘
|
||||
```
|
||||
## Где используется
|
||||
|
||||
Бэкенды эмбеддингов (через `AGENTIK_EMBEDDING_BACKEND`):
|
||||
- `:standalone` подключает как `AGENTIK_MEMORY_BACKEND=vector`
|
||||
(с `AGENTIK_EMBEDDING_BACKEND=http|siglip`).
|
||||
|
||||
- **`HTTP`** — POST на `${OPENAI_BASE_URL}/v1/embeddings`. Семантический поиск
|
||||
через OpenAI-совместимый endpoint (vLLM, OpenAI, LiteLLM-proxy).
|
||||
Кэширование LRU(256) на уровне `EmbeddingHttpProvider` для дедупликации.
|
||||
- **`SIGLIP`** — on-device SigLIP2 через ONNX Runtime (768-мерный вектор).
|
||||
Никаких внешних вызовов; модель и токенизатор должны лежать на диске.
|
||||
|
||||
## Подключение
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
plugins { kotlin("jvm") }
|
||||
|
||||
dependencies {
|
||||
implementation("pw.binom.agentik:memory-vector:$version")
|
||||
// Транзитивно: :memory-api + jvector + sqldelight + text-embedding-kmp
|
||||
implementation("pw.binom.agentik:memory-vector:0.1.0")
|
||||
implementation("pw.binom.agentik:memory-api:0.1.0")
|
||||
}
|
||||
|
||||
val memory = VectorMemorySystem.open(
|
||||
dbPath = Path("~/.agentik/mem.db"),
|
||||
embedding = HttpEmbeddingClient(
|
||||
apiUrl = "http://192.168.88.135:8001/v1",
|
||||
apiKey = "no-key-needed",
|
||||
model = "text-embedding-3-small",
|
||||
dimension = 1536,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`:standalone` инициализирует бэкенд автоматически по `AGENTIK_MEMORY_BACKEND=vector`
|
||||
+ `AGENTIK_EMBEDDING_BACKEND=…`.
|
||||
## Версии
|
||||
|
||||
## Размерности
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-memory-vector`.
|
||||
|
||||
| Backend | Модель | Dim |
|
||||
|---|---|---|
|
||||
| `HTTP` (OpenAI) | `text-embedding-3-small` (default) | 1536 |
|
||||
| `HTTP` (OpenAI) | `text-embedding-3-large` | 3072 |
|
||||
| `SIGLIP` | SigLIP2-base | 768 (фиксировано) |
|
||||
**Зависит от** `pw.binom.ai.embeddingtext:api-jvm:3.0.0-SNAPSHOT`
|
||||
и `pw.binom.ai.embeddingtext:siglip-jvm:3.0.0-SNAPSHOT` из репо
|
||||
`caffeine` (см. `../gradle/libs.versions.toml`). Оба опубликованы
|
||||
вручную (`Binom-PIN-Caffeine`).
|
||||
|
||||
Для `HTTP` размерность управляется через `AGENTIK_EMBEDDING_DIMENSION`; для
|
||||
`SIGLIP` — определяется автоматически.
|
||||
## Как работает embedding-флоу
|
||||
|
||||
## Где смотреть версии
|
||||
1. `memory.save(cat, "text")` — text → embedding (HTTP или SigLIP)
|
||||
→ row в SQLite + вектор в JVector-индекс.
|
||||
2. `memory.query(cat, "q")` — q → embedding → ANN top-K (default K=10)
|
||||
→ скоры, deduplication, реплес с timestamp.
|
||||
|
||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
## Тесты
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./gradlew :memory-vector:build
|
||||
```
|
||||
./gradlew :memory-vector:jvmTest
|
||||
```
|
||||
|
||||
JVM-only. Тянет `pw.binom.agentik:memory-api` и `com.github.jvector:jvector:3.0.6`.
|
||||
Покрывают: round-trip, ANN top-K, SigLIP (если модель скачана),
|
||||
SQLite-migration. SigLIP-тест skipped без модели на диске.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого HTTP-клиента к LLM для генерации ответов. Это только
|
||||
embedding-клиент. Сам LLM-вызов — в `:standalone`.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Подходит для крупных памятей (10000+
|
||||
заметок) и семантических запросов.
|
||||
|
||||
@@ -2,6 +2,16 @@ plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
||||
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
|
||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
||||
// Использование:
|
||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||
val skipVectorMemory: Boolean =
|
||||
(project.findProperty("skipVectorMemory") == "true") ||
|
||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
@@ -24,26 +34,14 @@ kotlin {
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.jvector)
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
// text-embedding-kmp — on-device SigLIP2 через ONNX Runtime.
|
||||
// Сигнатура `embed(String): TextEmbedding` (blocking), оборачиваем
|
||||
// наш `suspend fun embed(text)` через Mutex. api-вариант экспортируем
|
||||
// (`api`), потому что SiglipEmbeddingProvider реализует `embed()`
|
||||
// через тип TextEmbeddingExtractor, который виден потребителю
|
||||
// только если он сам подтянет api-jvm — проще пробросить.
|
||||
//
|
||||
// WORKAROUND: upstream `siglip-jvm/*.module` ссылается на `api`
|
||||
// БЕЗ -jvm суффикса. Поскольку в mavenLocal есть только `api-jvm`,
|
||||
// требуется дополнительный stub-jar `pw.binom.ai.embeddingtext:api`
|
||||
// с тем же содержимым. Создаётся так:
|
||||
// mkdir -p ~/.m2/repository/pw.binom.ai.embeddingtext/api/3.0.0-SNAPSHOT
|
||||
// cp ~/.m2/repository/.../api-jvm/3.0.0-SNAPSHOT/api-jvm-*.{jar,sources.jar} \
|
||||
// ~/.m2/repository/.../api/3.0.0-SNAPSHOT/api-*.{jar,sources.jar}
|
||||
// Когда upstream починит module-metadata — эту инструкцию можно убрать.
|
||||
api(libs.text.embedding.api)
|
||||
implementation(libs.text.embedding.siglip)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("jvmMainApi", libs.text.embedding.api)
|
||||
add("jvmMainImplementation", libs.text.embedding.siglip)
|
||||
}
|
||||
|
||||
+106
-67
@@ -1,94 +1,133 @@
|
||||
# :proto — `pw.binom.agentik.proto`
|
||||
# `:proto` — протокол общения с агентом (KMP, jvm + native)
|
||||
|
||||
**Stateful KMP-протокол взаимодействия клиента с агентом.**
|
||||
Замена AG-UI в проектах, где агенту нужно **самому владеть** историей диалога и
|
||||
контекстным окном (компакция, рефлексия, выбор инструментов) — клиент только
|
||||
стримит сообщения и рисует события.
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Типы и контракт in-house протокола `agentik`, заменившего AG-UI:
|
||||
|
||||
AG-UI требует, чтобы **клиент** слал полный `messages[]` на каждый ход, а агент
|
||||
оставался stateless. Это удобно для UI-чатов, но ломается, когда:
|
||||
- **stateful** — сервер сам владеет диалогом; клиент шлёт только новые
|
||||
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
|
||||
повторять `messages[]` каждый раз).
|
||||
- **declarative история vs. события** — `Message` это то, что уже легло
|
||||
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
|
||||
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
|
||||
`:proto`; это контракт.
|
||||
|
||||
- у агента есть долговременная память (md/vector), и контекст должен
|
||||
автоматически сжиматься / дополняться перед отправкой в LLM;
|
||||
- у одного пользователя десятки активных диалогов и нельзя каждый раз
|
||||
пересылать 100K токенов;
|
||||
- агент сам планирует вызовы инструментов и управляет KV-cache модели.
|
||||
Решает проблему: AG-UI клиент вынужден каждый раз знать и пересобирать
|
||||
полную историю, а его серверная часть (`AbstractAgent`) — постоянно
|
||||
сериализовать-десериализовать всю переписку. В `:proto` сервер один,
|
||||
контракт тонкий, переписка персистится нативно (SQLite, файлы, что
|
||||
хотите). Можно подменить front-end или back-end, протокол остаётся.
|
||||
|
||||
`:proto` переворачивает ответственность: **агент** владеет `Conversation.messages()`,
|
||||
`send(content)` отправляет только новый message, а `events(after): Flow<Event>`
|
||||
стримит live-события с `Instant`-таймстампом для отслеживания прогресса.
|
||||
## Где используется
|
||||
|
||||
## Контракт
|
||||
- `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE.
|
||||
- `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`.
|
||||
- `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
|
||||
*(`:agentik-tui` был исключён из сборки 2026-09-17.)*
|
||||
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
|
||||
`Message`/`Event` напрямую через storage.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:proto:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Артефакт `pw.binom.agentik:proto:0.1.0` живёт в Nexus-репозитории
|
||||
`caffeine` (HTTP `http://<your-nexus>/repository/caffeine/`, plain-HTTP,
|
||||
credentials — через переменные `binom.repo.user/password/url`).
|
||||
|
||||
## Версии
|
||||
|
||||
Каталог `gradle/libs.versions.toml`, секция `[versions]` → `agentik-proto`.
|
||||
Поднять версию → переопубликовать все KMP-таргеты через `./gradlew
|
||||
:proto:publish -Pversion=...` (или триггернуть Gitea release).
|
||||
|
||||
Текущие KMP-таргеты: `jvm + macosX64/macosArm64 +
|
||||
iosX64/iosArm64/iosSimulatorArm64 + linuxX64/linuxArm64 + mingwX64`.
|
||||
|
||||
## Публикация
|
||||
|
||||
Настройки в `gradle.properties` / env: `binom.repo.url`, `binom.repo.user`,
|
||||
`binom.repo.password`. `./gradlew :proto:publish` публикует все
|
||||
target-specific артефакты + общий `kotlinMultiplatform`.
|
||||
|
||||
## Основные типы
|
||||
|
||||
```kotlin
|
||||
interface Agent {
|
||||
val id: String
|
||||
suspend fun createConversation(temp: Boolean = false): Conversation
|
||||
fun id: String
|
||||
suspend fun createConversation(title: String? = null): Conversation
|
||||
suspend fun getConversation(id: String): Conversation?
|
||||
suspend fun getConversations(offset: Int = 0, limit: Int = PAGE_SIZE): List<Conversation>
|
||||
fun getConversations(offset: Int = 0): Flow<Conversation> // cold-flow по страницам
|
||||
fun events(after: Instant): Flow<AgentEvent> // live-уведомления о диалогах
|
||||
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
|
||||
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
|
||||
}
|
||||
|
||||
interface Conversation : AutoCloseable {
|
||||
val id: String
|
||||
val title: String?
|
||||
val updatedAt: Instant
|
||||
val isSupportImageInput: Boolean
|
||||
val isSupportImageOutput: Boolean
|
||||
suspend fun send(content: List<Content>): Unit // write-only, не блокирует
|
||||
fun events(after: Instant): Flow<Event> // live read (НЕ replay!)
|
||||
suspend fun getMessages(offset: Int, limit: Int = PAGE_SIZE): List<Message>
|
||||
fun getMessages(offset: Int = 0): Flow<Message> // cold-flow по страницам
|
||||
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
|
||||
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
|
||||
suspend fun getMessages(offset: Int = 0): Flow<Message>
|
||||
suspend fun rename(title: String): Boolean
|
||||
suspend fun interrupt(): Unit
|
||||
fun close() // освобождает ресурсы
|
||||
fun interrupt()
|
||||
}
|
||||
|
||||
sealed interface Content {
|
||||
class Text(val body: String) : Content
|
||||
class Image(val data: ByteArray, val mime: String) : Content
|
||||
}
|
||||
|
||||
sealed interface Message {
|
||||
val id: String
|
||||
val date: Instant
|
||||
interface Body : Message { val content: List<Content> }
|
||||
interface System : Message
|
||||
class UserMessage(...) : Body
|
||||
class AssistantMessage(...) : Body
|
||||
class ToolCall(...) : System
|
||||
class ToolResult(...) : System
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
enum ResponseType { TEXT, IMAGE }
|
||||
class StartReasoning(...) : Event
|
||||
class StartResponse(val type: ResponseType) : Event
|
||||
class AppendText(val body: String) : Event
|
||||
class AppendImage(val body: ByteArray, val mime: String) : Event
|
||||
class End(...) : Event
|
||||
class Interrupted(...) : Event
|
||||
class Error(val message: String, val code: Int? = null) : Event
|
||||
class ToolCall(...) : Event
|
||||
class ToolResult(...) : Event
|
||||
}
|
||||
```
|
||||
|
||||
Иерархии:
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- `Content` — `Text` / `Image(data: ByteArray, mime: String)`
|
||||
- `Message` — `UserMessage` / `AssistantMessage` (оба `Body`) + `ToolCall(id, name, args)` / `ToolResult(id, result)` / `System` для метаданных
|
||||
- `Event` — `StartReasoning` / `StartResponse(type)` / `AppendText` / `AppendImage` / `End` / `Interrupted` / `Error`
|
||||
- `AgentEvent` — `Created(id)` / `Deleted(id)` / `Renamed(id, title)`
|
||||
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||
и `:client`.
|
||||
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
||||
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||
внутри `:standalone` (ChatAgent) и выше.
|
||||
|
||||
Live-стримы (`events(after)`) **не реплеят** прошедшие события — клиент должен
|
||||
сам вызвать `getMessages(after)` для бэкфилла, либо подписаться на `events(after=now)`
|
||||
и начать рисовать с настоящего момента.
|
||||
## Тесты
|
||||
|
||||
## Подключение
|
||||
|
||||
Артефакт — `pw.binom.agentik:proto:VERSION`.
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
implementation("pw.binom.agentik:proto:$version")
|
||||
// JVM-only
|
||||
implementation("pw.binom.agentik:proto-jvm:$version")
|
||||
// Любой KMP-таргет
|
||||
implementation("pw.binom.agentik:proto-macosArm64:$version")
|
||||
```
|
||||
./gradlew :proto:jvmTest
|
||||
./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
|
||||
```
|
||||
|
||||
`version` синхронизируется с `gradle.properties` (`version=0.1.0`) и
|
||||
прокидывается через `-Pversion=...` в CI (`binom.repo.*` для Nexus).
|
||||
## Текущий статус
|
||||
|
||||
## Где смотреть версии
|
||||
|
||||
- текущая разрабатываемая: `gradle.properties` → `version=0.1.0`
|
||||
- история релизов: `https://git.binom.pw/subochev/agentik/releases`
|
||||
- опубликованные артефакты: Nexus-репозиторий `caffeine`
|
||||
(`http://nexus.xx/repository/caffeine/pw/binom/agentik/proto/`)
|
||||
|
||||
## Сборка / тесты
|
||||
|
||||
```bash
|
||||
./gradlew :proto:build # все KMP-таргеты + тесты
|
||||
./gradlew :proto:jvmTest # только JVM-тесты
|
||||
./gradlew :proto:publishToMavenLocal # для локального потребления
|
||||
```
|
||||
|
||||
KMP-таргеты: `jvm + macosX64 + macosArm64 + iosX64 + iosArm64 + iosSimulatorArm64 + linuxX64 + linuxArm64 + mingwX64`.
|
||||
Зависимостей минимум: `kotlinx-coroutines-core:1.11.0` + `kotlinx-datetime:0.8.0` (оба `api`).
|
||||
Используется продакшеном. Иммутабельный API (после рефакторинга из
|
||||
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
|
||||
contents, server-pushed references — все обсуждаются через общий
|
||||
[IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
|
||||
|
||||
+66
-44
@@ -1,66 +1,88 @@
|
||||
# :server — `pw.binom.agentik.server`
|
||||
# `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
|
||||
|
||||
**Ktor-маршруты, превращающие `pw.binom.agentik.proto.Agent` в HTTP+JSON+SSE фасад.**
|
||||
Подключается к любому `Application` через `Route.agentikAgent(...)`, монтируется
|
||||
под заданным `path` (по умолчанию `/agentik`).
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
|
||||
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
|
||||
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
|
||||
`GET /agentik/conversations`.
|
||||
|
||||
`:proto` — это чистый Kotlin-контракт (Agent/Conversation). Чтобы по нему
|
||||
говорить с удалённым сервером нужен мост. `:server` — этот мост, но **только
|
||||
маршруты**: без привязки к конкретному engine (CIO/Netty), без агрегации
|
||||
прочих протоколов (AG-UI/A2A), без инициализации БД/памяти. Один extension-метод
|
||||
на `Route`, и ваш Agent доступен по HTTP.
|
||||
- **stateful** — сервер не принимает полную историю, только новые
|
||||
сообщения. История хранится там, где развёрнут `Agent`.
|
||||
- **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
|
||||
обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
|
||||
с snake_case-дискриминаторами).
|
||||
|
||||
## Что отдаёт
|
||||
Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
|
||||
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` (через `:standalone`) | liveness |
|
||||
| `POST` | `/agentik/conversations` | создать диалог |
|
||||
| `GET` | `/agentik/conversations` | список диалогов (постраничный) |
|
||||
| `GET` | `/agentik/conversations/{id}` | конкретный диалог |
|
||||
| `POST` | `/agentik/conversations/{id}/rename` | переименовать |
|
||||
| `DELETE` | `/agentik/conversations/{id}` | удалить |
|
||||
| `POST` | `/agentik/conversations/{id}/messages` | отправить сообщение |
|
||||
| `GET` | `/agentik/conversations/{id}/events` | SSE-стрим событий |
|
||||
| `GET` | `/agentik/conversations/{id}/messages` | backfill истории (с `after`) |
|
||||
## Где используется
|
||||
|
||||
Wire-форма — `kotlinx.serialization` JSON со `snake_case`-дискриминаторами
|
||||
(`"kind":"start_response"`, `"kind":"user_message"`, и т.п.). `Instant`
|
||||
сериализуется ISO-8601 строкой (`agentikJson` в `Serialization.kt`).
|
||||
- `:standalone` подключает `Route.agentikAgent(agent)` в свой
|
||||
embedded Netty engine.
|
||||
- Любые клиенты (наши `:client`, `:agentik-cli`, или
|
||||
внешние web-фронтенды) идут через этот контракт.
|
||||
|
||||
## Подключение
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
// your-application/build.gradle.kts
|
||||
implementation("pw.binom.agentik:server:$version")
|
||||
implementation("io.ktor:ktor-server-core:2.3.x") // или любой совместимый
|
||||
implementation("io.ktor:ktor-server-cio:2.3.x") // engine — ваш выбор
|
||||
// build.gradle.kts (KMP JVM target)
|
||||
plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
|
||||
dependencies {
|
||||
api("pw.binom.agentik:server:0.1.0")
|
||||
api("pw.binom.agentik:proto:0.1.0")
|
||||
}
|
||||
|
||||
// ваш код
|
||||
import pw.binom.agentik.server.agentikAgent
|
||||
|
||||
fun Application.module() {
|
||||
// ваш код:
|
||||
fun Application.module(agent: Agent) {
|
||||
install(ContentNegotiation) { json(agentikJson) }
|
||||
install(SSE)
|
||||
routing {
|
||||
agentikAgent(agent = myAgent) // mount на /agentik
|
||||
agentikAgent(agent = myAgent, path = "/v1/agent") // или под другим путём
|
||||
route("/agentik") { agentikAgent(agent) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Где смотреть версии
|
||||
## Версии
|
||||
|
||||
- `pw.binom.agentik:proto` (см. `:proto/README.md`)
|
||||
- `pw.binom.agentik:server` — `version` берётся из `gradle.properties` (`version=0.1.0`)
|
||||
- история релизов: `https://git.binom.pw/subochev/agentik/releases`
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-server`.
|
||||
|
||||
## Сборка
|
||||
## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
|
||||
|
||||
```bash
|
||||
./gradlew :server:build
|
||||
| Метод | Путь | Что делает |
|
||||
|---|---|---|
|
||||
| `POST` | `/conversations` | Создать диалог (body: `{title?}`) |
|
||||
| `GET` | `/conversations` | Список диалогов (по `?offset=&limit=`) |
|
||||
| `GET` | `/conversations/:id` | Снимок диалога + count |
|
||||
| `GET` | `/conversations/:id/messages` | История сообщений (по `?after=`) |
|
||||
| `POST` | `/conversations/:id/rename` | Переименовать (body: `{title}`) |
|
||||
| `DELETE` | `/conversations/:id` | Удалить |
|
||||
| `POST` | `/conversations/:id/send` | Send-флоу (body: `{content:[…]}` → SSE) |
|
||||
| `GET` | `/conversations/:id/events` | Live подписка (SSE) |
|
||||
| `POST` | `/conversations/:id/interrupt` | Прервать текущий `send()` |
|
||||
|
||||
`Content-Type: text/event-stream` всегда для SSE, ноль-лишних
|
||||
заголовков. Сообщения: `event: <name>` (`message`, `start_reasoning`,
|
||||
`start_response`, `append_text`, `append_image`, `end`, `interrupted`,
|
||||
`error`) + `data: <JSON>`.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
./gradlew :server:jvmTest
|
||||
```
|
||||
|
||||
KMP-таргеты: те же, что у `:proto` (jvm + 8 нативов).
|
||||
Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
|
||||
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
|
||||
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
|
||||
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
|
||||
добавляются только с snake_case-дискриминаторами и строго обратно
|
||||
совместимо.
|
||||
|
||||
@@ -37,6 +37,8 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.ktor.server.test.host)
|
||||
implementation(libs.ktor.server.cio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package pw.binom.agentik.server
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.request.path
|
||||
import io.ktor.server.response.respondText
|
||||
|
||||
/**
|
||||
* Конфиг плагина проверки `Authorization: Bearer <token>` для роута `agentikAgent`.
|
||||
*
|
||||
* По умолчанию [token] == null → плагин пропускает все запросы (см. [Module.kt]).
|
||||
*/
|
||||
internal class BearerTokenConfig {
|
||||
var token: String? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-scoped плагин: если в конфиге задан [BearerTokenConfig.token], на каждый
|
||||
* запрос внутри ветки роута проверяет заголовок `Authorization: Bearer <token>`.
|
||||
* При несовпадении отвечает `401 Unauthorized` (тело `Unauthorized`); дальнейшие
|
||||
* обработчики не вызываются — ktor трактует отправленный ответ как завершение
|
||||
* call-pipeline.
|
||||
*
|
||||
* `/health` всегда пропускается без проверки: это ручка liveness для
|
||||
* балансировщика/мониторинга, закрывать её — сломать health-check.
|
||||
*/
|
||||
internal val BearerTokenPlugin = createRouteScopedPlugin(
|
||||
name = "AgentikBearerToken",
|
||||
createConfiguration = ::BearerTokenConfig,
|
||||
) {
|
||||
val expected = pluginConfig.token
|
||||
onCall { call ->
|
||||
if (expected == null) return@onCall
|
||||
val path = call.request.path()
|
||||
if (path.endsWith("/health")) return@onCall
|
||||
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
|
||||
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,16 @@ import pw.binom.agentik.proto.Agent
|
||||
* - `GET /events` — SSE: события агента
|
||||
* - `GET /health` — `"ok"`
|
||||
*/
|
||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
|
||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? = null) {
|
||||
route(path) {
|
||||
install(ContentNegotiation) {
|
||||
json(agentikJson)
|
||||
}
|
||||
if (token != null) {
|
||||
install(BearerTokenPlugin) {
|
||||
this.token = token
|
||||
}
|
||||
}
|
||||
agentikRoutes(agent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package pw.binom.agentik.server
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.cio.CIO as ServerCIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.AgentEvent
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Тесты route-scoped плагина [BearerTokenPlugin]:
|
||||
* - при `token != null` все роуты кроме `/health` требуют `Authorization: Bearer <token>`;
|
||||
* - при `token == null` плагин не устанавливается, всё открыто;
|
||||
* - `/health` всегда открыт, даже при заданном токене (liveness-ручка для балансировщика).
|
||||
*/
|
||||
class BearerTokenTest {
|
||||
|
||||
private class FakeAgent(override val id: String = "test") : Agent {
|
||||
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
|
||||
override suspend fun getConversation(id: String): Conversation? = null
|
||||
override suspend fun deleteConversation(id: String): Boolean = false
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList()
|
||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
||||
}
|
||||
|
||||
private suspend fun startServer(token: String?): Pair<EmbeddedServer<*, *>, Int> {
|
||||
val server = embeddedServer(ServerCIO, port = 0) {
|
||||
routing {
|
||||
agentikAgent(FakeAgent(), path = "/agentik", token = token)
|
||||
}
|
||||
}.start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
return server to port
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenRejectsRequestWithoutHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
assertEquals("Unauthorized", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenRejectsWrongHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||
header(HttpHeaders.Authorization, "Bearer wrong")
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokenAcceptsCorrectHeader() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") {
|
||||
header(HttpHeaders.Authorization, "Bearer secret")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun healthStaysOpenWithToken() = runBlocking {
|
||||
val (server, port) = startServer("secret")
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/health")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
assertEquals("ok", resp.bodyAsText())
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullTokenMeansOpen() = runBlocking {
|
||||
val (server, port) = startServer(null)
|
||||
try {
|
||||
val client = HttpClient(CIO)
|
||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||
assertEquals(HttpStatusCode.OK, resp.status)
|
||||
} finally {
|
||||
server.stop(100, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -28,6 +28,14 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "agentik"
|
||||
|
||||
include(":standalone")
|
||||
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
include(":llm-tools")
|
||||
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
|
||||
// из :standalone — MCP не специфичен для standalone'а, это generic мост
|
||||
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool).
|
||||
include(":mcp-bridge")
|
||||
// Собственный протокол agentik. Пока в нём пилим, потом вынесем.
|
||||
include(":proto")
|
||||
// Парсер скилов (YAML-frontmatter + markdown body, opencode-style).
|
||||
@@ -41,7 +49,9 @@ include(":client")
|
||||
include(":agentik-cli")
|
||||
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
|
||||
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
|
||||
include(":agentik-tui")
|
||||
// include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
|
||||
// Папка agentik-tui/ оставлена на диске для возможного возврата; из сборки исключена.
|
||||
|
||||
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
||||
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
||||
include(":memory-api")
|
||||
|
||||
+65
-63
@@ -1,77 +1,79 @@
|
||||
# :skills — `pw.binom.agentik.skills`
|
||||
# `:skills` — парсер SKILL.md (KMP, JVM-only)
|
||||
|
||||
**Парсер opencode-style скилов: `SKILL.md` или `*.yaml` с YAML-frontmatter + markdown body.**
|
||||
Загружается в system prompt как отдельная секция; модели доступны тулы
|
||||
`read_skill` / `skill_save` / `skill_delete` (если они подключены через
|
||||
`:agent-toolsets`).
|
||||
## Что это
|
||||
|
||||
## Какую проблему решает
|
||||
Парсер и runtime для навыков агента в формате [opencode Skills](https://docs.opencode.dev):
|
||||
|
||||
Агенту нужно **знать**, какие процедуры/инструкции у него есть, не таская их
|
||||
в коде. Скил — это:
|
||||
- **SKILL.md / \*.yaml** с YAML-frontmatter (`name`, `description`,
|
||||
`allowed-tools`, etc.) и markdown-телом.
|
||||
- Реестр `SkillCatalog`, лоадер `SkillLoader` (поиск по
|
||||
`~/.agentik/skills/`).
|
||||
- `Skill` имеет стабильный id, описание, может требовать определённые
|
||||
tools (`allowed-tools: [run_command, write_file]`) — это контролируется
|
||||
на уровне вызова.
|
||||
- Загруженные скиллы аггрегируются в system-prompt через
|
||||
`Skill.toSystemPromptSection()` или подгружаются по требованию через
|
||||
tool `read_skill`.
|
||||
|
||||
Решает задачу: агенту нужно объяснить "что я умею" на разных языках
|
||||
(нативный skill-вызов vs. описание), нужно уметь включать/выключать
|
||||
навыки по требованию, и нужно хранить текстовые навыки прямо в
|
||||
git-репозитории (а не в БД).
|
||||
|
||||
## Где используется
|
||||
|
||||
- `:standalone` подгружает все SKILL.md из `~/.agentik/skills/`
|
||||
и инструментового скилл-майнера (skill-mining: создание новых
|
||||
SKILL.md по LLM-рефлексии).
|
||||
- Активно юзается для: `code-review`, `arch-summary`, `telegram-reply`,
|
||||
любых "habits" агента.
|
||||
|
||||
## Как подключить
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
api("pw.binom.agentik:skills:0.1.0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Версии
|
||||
|
||||
`gradle/libs.versions.toml` → `[versions] agentik-skills`.
|
||||
|
||||
## Пример SKILL.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: backend:spring:db-base
|
||||
description: Правила JPA/Flyway миграций и репозиториев в agentik.
|
||||
name: code-review
|
||||
description: Review uncommitted diff and produce line-anchored comments.
|
||||
allowed-tools: [run_command, read_file]
|
||||
---
|
||||
Правила, которые применяются ко всем изменениям схемы:
|
||||
- миграции только в Flyway;
|
||||
- новые колонки nullable by default;
|
||||
- ...
|
||||
|
||||
You are a strict reviewer. For every change in the diff, output:
|
||||
- File: <path>
|
||||
- Severity: <nit|warning|blocker>
|
||||
- Comment: <one sentence>
|
||||
|
||||
Only mention issues that are objectively wrong. Do not refactor.
|
||||
```
|
||||
|
||||
Парсер читает такие файлы из каталога (рекурсивно), валидирует обязательные
|
||||
поля (`name`, `description`), собирает каталог для system prompt и **поддерживает
|
||||
горячее обновление**: добавил файл — доступен в следующем `read_skill` без
|
||||
рестарта.
|
||||
## Тесты
|
||||
|
||||
## Формат
|
||||
|
||||
Поддерживаются оба варианта:
|
||||
|
||||
- **`SKILL.md`** — единый файл в каталоге (с frontmatter + body).
|
||||
- **`*.yaml`** + опциональный `*.md`-компаньон с тем же basename.
|
||||
|
||||
Frontmatter — YAML, минимум `name` (с `:` для вложенности) и `description`.
|
||||
Остальные поля — пользовательские, доступны через `SkillFile.frontmatter`.
|
||||
|
||||
## Подключение
|
||||
|
||||
```kotlin
|
||||
commonMain {
|
||||
implementation("pw.binom.agentik:skills:$version")
|
||||
implementation("com.charleskorn.kaml:kaml:0.55.0")
|
||||
}
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
|
||||
val catalog = SkillCatalog.fromDirectory(Path("/etc/agentik/skills"))
|
||||
catalog.listAll().forEach { skill ->
|
||||
println("- ${skill.name}: ${skill.description}")
|
||||
}
|
||||
|
||||
val skill = catalog.findByName("backend:spring:db-base")
|
||||
val body = skill?.body
|
||||
```
|
||||
|
||||
`SkillPrompt` умеет отрендерить каталог в markdown-секцию для system prompt
|
||||
(с truncate по длине, чтобы не раздувать контекст).
|
||||
|
||||
## Где смотреть версии
|
||||
|
||||
- `version` из `gradle.properties` (`version=0.1.0`)
|
||||
- релизы: `https://git.binom.pw/subochev/agentik/releases`
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
./gradlew :skills:build
|
||||
./gradlew :skills:jvmTest
|
||||
```
|
||||
|
||||
KMP-таргеты — полный набор как у `:proto`. Зависимости — только `kotlinx-serialization` + `kaml`.
|
||||
Покрывают: парсинг yaml-frontmatter, обработку отсутствующих полей,
|
||||
unicode-имена, дубликаты id, очень большое тело.
|
||||
|
||||
## Чего здесь НЕТ
|
||||
|
||||
- Никакого HTTP / tool-вызова. Парсер и реестр — не более.
|
||||
- Никакой БД. SKILL.md живут в файлах под управлением пользователя.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Используется продакшеном. Парсер простой и предсказуемый; расширять
|
||||
формат frontmatter можно без поломок (новые поля игнорируются).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user