feat(deploy): platform handoff — agent routing, persistence, docs cleanup
Agent routing: - Remove !agent command and manual agent selection flow - Registry auto-assigns agent from user_agents mapping (fallback: agents[0]) - provision_workspace_chat and !new both write agent_id to room_meta - Reconciliation backfills agent_id from registry on cold start - Fix duplicate agent_id block in auth.py Deployment stability: - Add bot-state named volume to persist lambda_matrix.db and matrix_store - Fix docker-compose.prod.yml duplicate environment: key (was silently losing all Matrix credentials) - Fix MATRIX_AGENT_REGISTRY_PATH to use absolute container path /app/config/... - Add bot-state volume declaration to docker-compose.fullstack.yml Docs and config: - Rewrite README.md for platform handoff (deploy table, working commands only) - Rewrite docs/matrix-prototype.md (remove stale commands and mock descriptions) - Remove !save/!load/!context/!agent from help text and welcome message - Add !clear, !list, !remove, !yes/!no to help text - Clean up .env.example (remove Telegram token, internal vars, real URLs) - Update config/matrix-agents.example.yaml with user_agents section and comments - Add explanatory comment to Dockerfile for --ignore-requires-python - Remove silent uv sync fallbacks in Dockerfile
This commit is contained in:
parent
380961d6e9
commit
b1aaa210a1
21 changed files with 311 additions and 937 deletions
39
.env.example
39
.env.example
|
|
@ -1,25 +1,24 @@
|
||||||
# Telegram
|
# Matrix bot credentials
|
||||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
MATRIX_HOMESERVER=https://matrix.example.org
|
||||||
|
MATRIX_USER_ID=@lambda-bot:example.org
|
||||||
# Matrix
|
# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN
|
||||||
MATRIX_HOMESERVER=https://matrix.org
|
|
||||||
MATRIX_USER_ID=@bot:matrix.org
|
|
||||||
MATRIX_PASSWORD=your_password_here
|
MATRIX_PASSWORD=your_password_here
|
||||||
|
# MATRIX_ACCESS_TOKEN=your_access_token_here
|
||||||
|
|
||||||
|
# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only)
|
||||||
MATRIX_PLATFORM_BACKEND=real
|
MATRIX_PLATFORM_BACKEND=real
|
||||||
MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
|
|
||||||
|
|
||||||
# Shared /agents contract for Phase 05 deployment
|
# Path to agent registry inside the container (mounted via ./config:/app/config:ro)
|
||||||
|
MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml
|
||||||
|
|
||||||
|
# HTTP URL of the platform-agent endpoint
|
||||||
|
# Production: external agent managed by the platform
|
||||||
|
# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml
|
||||||
|
AGENT_BASE_URL=http://your-agent-host:8000
|
||||||
|
|
||||||
|
# Shared volume path inside the bot container (default: /agents)
|
||||||
SURFACES_WORKSPACE_DIR=/agents
|
SURFACES_WORKSPACE_DIR=/agents
|
||||||
|
|
||||||
|
# Docker volume names (created automatically on first run)
|
||||||
SURFACES_SHARED_VOLUME=surfaces-agents
|
SURFACES_SHARED_VOLUME=surfaces-agents
|
||||||
|
SURFACES_BOT_STATE_VOLUME=surfaces-bot-state
|
||||||
# Production handoff: point the bot at the externally managed agent endpoint.
|
|
||||||
AGENT_BASE_URL=https://lambda.coredump.ru/agent_0/
|
|
||||||
|
|
||||||
# Internal full-stack compose defaults
|
|
||||||
AGENT_ID=matrix-dev
|
|
||||||
COMPOSIO_API_KEY=
|
|
||||||
|
|
||||||
# platform-agent provider
|
|
||||||
PROVIDER_MODEL=openai/gpt-4o-mini
|
|
||||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
|
||||||
PROVIDER_API_KEY=sk-or-...
|
|
||||||
|
|
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -13,15 +13,17 @@ RUN pip install --no-cache-dir uv
|
||||||
COPY pyproject.toml uv.lock* ./
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
|
||||||
# Install project dependencies into the system environment.
|
# Install project dependencies into the system environment.
|
||||||
RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project
|
RUN uv sync --no-dev --no-install-project --frozen
|
||||||
|
|
||||||
# Copy project source after dependency layers.
|
# Copy project source after dependency layers.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Install the project itself and keep runtime dependencies in sync.
|
# Install the project itself.
|
||||||
RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev
|
RUN uv sync --no-dev --frozen
|
||||||
|
|
||||||
# Install lambda_agent_api from the local source tree, bypassing its Python version guard.
|
# Install lambda_agent_api from the vendored source tree.
|
||||||
|
# --ignore-requires-python: the package declares python<3.12 but works fine on 3.11;
|
||||||
|
# the guard exists for its own dev tooling, not the runtime API surface we use.
|
||||||
RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
|
RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api
|
||||||
|
|
||||||
CMD ["python", "-m", "adapter.matrix.bot"]
|
CMD ["python", "-m", "adapter.matrix.bot"]
|
||||||
|
|
|
||||||
339
README.md
339
README.md
|
|
@ -1,23 +1,10 @@
|
||||||
# Lambda Lab 3.0 — Surfaces
|
# Lambda Lab 3.0 — Surfaces
|
||||||
|
|
||||||
Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
|
Matrix-бот для взаимодействия пользователя с AI-агентом Lambda.
|
||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
| Поверхность | Статус |
|
Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff.
|
||||||
|---|---|
|
|
||||||
| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` |
|
|
||||||
| Matrix | ✅ MVP runtime: `docker-compose.prod.yml` для bot-only handoff, `docker-compose.fullstack.yml` для internal E2E |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Концепция
|
|
||||||
|
|
||||||
Пользователь получает персонального AI-агента через привычный мессенджер.
|
|
||||||
Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём.
|
|
||||||
|
|
||||||
**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы.
|
|
||||||
Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -28,271 +15,173 @@ surfaces-bot/
|
||||||
core/ — общее ядро, не зависит от транспорта
|
core/ — общее ядро, не зависит от транспорта
|
||||||
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
|
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
|
||||||
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
|
handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
|
||||||
handlers/ — обработчики по типам событий
|
|
||||||
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
|
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
|
||||||
chat.py — ChatManager: метаданные чатов C1/C2/C3
|
chat.py — ChatManager
|
||||||
auth.py — AuthManager: аутентификация
|
auth.py — AuthManager
|
||||||
settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность
|
settings.py — SettingsManager
|
||||||
|
|
||||||
adapter/
|
adapter/
|
||||||
telegram/ — aiogram 3.x адаптер
|
|
||||||
matrix/ — matrix-nio адаптер
|
matrix/ — matrix-nio адаптер
|
||||||
|
|
||||||
sdk/
|
sdk/
|
||||||
interface.py — PlatformClient Protocol (контракт к SDK)
|
interface.py — PlatformClient Protocol (контракт к SDK)
|
||||||
mock.py — MockPlatformClient (заглушка)
|
real.py — RealPlatformClient (через AgentApi)
|
||||||
|
mock.py — MockPlatformClient (заглушка для тестов)
|
||||||
|
|
||||||
|
config/
|
||||||
|
matrix-agents.yaml — реестр агентов
|
||||||
|
|
||||||
docs/ — документация
|
docs/ — документация
|
||||||
.claude/agents/ — агенты для Claude Code
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер.
|
Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
|
||||||
Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Функционал прототипа
|
## Деплой
|
||||||
|
|
||||||
### Telegram ([подробнее](docs/telegram-prototype.md))
|
### Переменные окружения
|
||||||
|
|
||||||
- **Чаты** — основной Telegram UX сейчас развивается в отдельном worktree `feat/telegram-adapter`
|
|
||||||
- **Forum Topics mode** — бот умеет подключать forum-группу через `/forum`; чат может быть привязан к отдельной теме
|
|
||||||
- **DM-режим** — базовый диалог и переключение чатов сохраняются
|
|
||||||
- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы
|
|
||||||
- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки
|
|
||||||
- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка
|
|
||||||
|
|
||||||
### Matrix ([подробнее](docs/matrix-prototype.md))
|
|
||||||
|
|
||||||
- **Онбординг** — при первом invite бот создаёт private Space `Lambda — {display_name}` и первую комнату `Чат 1`, сразу приглашая туда пользователя
|
|
||||||
- **Чаты** — `!new`, `!chats`, `!rename`, `!archive`, `!help`; новые комнаты регистрируются в локальном `ChatManager`
|
|
||||||
- **Диалог** — сообщения, вложения, подтверждения `!yes` / `!no` и routing через `EventDispatcher`
|
|
||||||
- **Стабильность** — перед `sync_forever()` бот делает bootstrap sync и стартует с `since`, чтобы не переигрывать старую timeline после рестарта
|
|
||||||
- **Текущее ограничение** — encrypted DM официально не поддержан; ручное тестирование Matrix ведётся в незашифрованных комнатах и зависит от локального state-store бота
|
|
||||||
- **Backend selection** — `MATRIX_PLATFORM_BACKEND=mock` остаётся значением по умолчанию; `MATRIX_PLATFORM_BACKEND=real` использует `platform-agent` из compose и upstream `AgentApi` по contract `/v1/agent_ws/{chat_id}/`
|
|
||||||
- **Ограничения real backend** — локальный runtime использует shared `/workspace`, файлы передаются как относительные пути в `attachments`, а transport layer со стороны `surfaces` использует прямой upstream `platform-agent_api.AgentApi` без локального subclass; prod-default lifecycle открывает отдельное соединение на каждый запрос, но после tool/file flow всё ещё остаётся подтверждённый upstream streaming bug, из-за которого начало ответа может пропадать
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Замена SDK
|
|
||||||
|
|
||||||
Вся работа с платформой идёт через `PlatformClient` Protocol:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class PlatformClient(Protocol):
|
|
||||||
async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ...
|
|
||||||
async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ...
|
|
||||||
async def get_settings(self, user_id: str) -> UserSettings: ...
|
|
||||||
async def update_settings(self, user_id: str, action: Any) -> None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Бот не управляет lifecycle контейнеров — это делает Master (платформа).
|
|
||||||
Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
|
|
||||||
|
|
||||||
Сейчас: `MockPlatformClient` в `sdk/mock.py`, а Matrix real backend собирается через `sdk/real.py` при `MATRIX_PLATFORM_BACKEND=real`.
|
|
||||||
Файловый контракт уже path-based: бот пишет файлы в shared `/agents` и передаёт платформе относительные пути в `attachments`, которые агент читает внутри своего `/workspace`.
|
|
||||||
Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Запуск Matrix-поверхности
|
|
||||||
|
|
||||||
### 1. Зависимости и тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
pytest tests/ -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Переменные окружения
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Обязательные переменные:
|
| Переменная | Обязательна | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `MATRIX_HOMESERVER` | ✓ | URL Matrix-сервера |
|
||||||
|
| `MATRIX_USER_ID` | ✓ | `@bot:example.org` |
|
||||||
|
| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) |
|
||||||
|
| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна |
|
||||||
|
| `AGENT_BASE_URL` | ✓ | HTTP-URL агента, например `http://platform-agent:8000` |
|
||||||
|
| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` |
|
||||||
|
| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) |
|
||||||
|
| `SURFACES_SHARED_VOLUME` | | имя Docker volume (по умолчанию `surfaces-agents`) |
|
||||||
|
|
||||||
```env
|
### Реестр агентов
|
||||||
# Matrix аккаунт бота
|
|
||||||
MATRIX_HOMESERVER=https://matrix.example.org
|
|
||||||
MATRIX_USER_ID=@lambda-bot:example.org
|
|
||||||
MATRIX_PASSWORD=... # или MATRIX_ACCESS_TOKEN=...
|
|
||||||
|
|
||||||
# Выбор backend: mock (по умолчанию) или real (подключение к platform-agent)
|
`config/matrix-agents.yaml` — статический маппинг пользователей на агентов:
|
||||||
MATRIX_PLATFORM_BACKEND=real
|
|
||||||
|
|
||||||
# production handoff: bot connects to externally managed agent endpoint
|
```yaml
|
||||||
AGENT_BASE_URL=https://lambda.coredump.ru/agent_0/
|
user_agents:
|
||||||
SURFACES_WORKSPACE_DIR=/agents
|
"@user0:matrix.lambda.coredump.ru": agent-0
|
||||||
SURFACES_SHARED_VOLUME=surfaces-agents
|
"@user1:matrix.lambda.coredump.ru": agent-1
|
||||||
|
|
||||||
# internal full-stack compose defaults
|
agents:
|
||||||
AGENT_ID=matrix-dev
|
- id: agent-0
|
||||||
|
label: "Agent 0"
|
||||||
# platform-agent provider
|
- id: agent-1
|
||||||
PROVIDER_MODEL=openai/gpt-4o-mini
|
label: "Agent 1"
|
||||||
PROVIDER_URL=https://openrouter.ai/api/v1
|
|
||||||
PROVIDER_API_KEY=...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Registry агентов
|
Если `user_agents` не задан или пользователь не найден — используется первый агент из списка.
|
||||||
|
|
||||||
1. Скопируй `config/matrix-agents.example.yaml` в `config/matrix-agents.yaml`
|
### Production (bot-only)
|
||||||
2. Если готовишься к multi-agent routing, добавь `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` в `.env`
|
|
||||||
3. Этот registry сейчас является конфигурационным артефактом Task 1; текущий Matrix runtime его ещё не читает
|
|
||||||
|
|
||||||
### 4. Compose artifacts
|
|
||||||
|
|
||||||
Production handoff uses `docker-compose.prod.yml`.
|
|
||||||
Этот файл поднимает только `matrix-bot`, монтирует shared volume в `/agents` и ожидает, что `AGENT_BASE_URL`
|
|
||||||
указывает на уже управляемый внешней платформой agent endpoint.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env -f docker-compose.prod.yml up -d --build
|
docker compose --env-file .env -f docker-compose.prod.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Internal full-stack E2E uses `docker-compose.fullstack.yml`.
|
Поднимает только `matrix-bot`. Монтирует shared volume в `/agents`. Требует внешний `AGENT_BASE_URL`.
|
||||||
Этот файл поднимает `matrix-bot` вместе с локальным `platform-agent`, использует тот же shared volume
|
|
||||||
(`SURFACES_SHARED_VOLUME`) и ждёт `service_healthy` вместо sleep-based sequencing.
|
### Fullstack E2E (bot + agent)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env -f docker-compose.fullstack.yml up --build
|
docker compose --env-file .env -f docker-compose.fullstack.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.fullstack.yml` собирает `platform-agent` из актуального upstream `external/platform-agent`
|
Поднимает `matrix-bot` вместе с локальным `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте.
|
||||||
(`development` target), монтирует live-код из `external/platform-agent/src` и `external/platform-agent_api`,
|
|
||||||
а shared volume виден как `/agents` в bot container и как `/workspace` в `platform-agent`.
|
|
||||||
Старый root compose harness остаётся только как historical local reference и больше не является рекомендуемым runtime path.
|
|
||||||
|
|
||||||
На `2026-04-21` локальный compose runtime использует vendored upstream-версии платформы без локальных патчей:
|
### Сброс состояния (локально)
|
||||||
|
|
||||||
- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
|
|
||||||
- `platform-agent_api`: `8a4f4db6d36786fe8af7feefffe506d4a54ac6bd`
|
|
||||||
|
|
||||||
### 4. Staged attachments в Matrix
|
|
||||||
|
|
||||||
Если Matrix-клиент отправляет файлы отдельными media events, бот не вызывает агента сразу.
|
|
||||||
Вместо этого он сохраняет файлы в shared `/agents`, ставит их в очередь для конкретного чата и пользователя, и ждёт следующего обычного сообщения.
|
|
||||||
|
|
||||||
Как отправить файлы агенту:
|
|
||||||
|
|
||||||
1. Отправь один или несколько файлов в рабочую Matrix-комнату.
|
|
||||||
2. При необходимости проверь очередь командой `!list`.
|
|
||||||
3. Напиши обычное текстовое сообщение, например:
|
|
||||||
- `что на изображении?`
|
|
||||||
- `прочитай pdf и сделай summary`
|
|
||||||
- `сравни эти два файла`
|
|
||||||
4. Это сообщение уйдёт агенту вместе со всеми staged файлами из очереди.
|
|
||||||
|
|
||||||
Команды:
|
|
||||||
|
|
||||||
- `!list` — показать staged вложения
|
|
||||||
- `!remove <n>` — удалить вложение по номеру
|
|
||||||
- `!remove all` — очистить все staged вложения
|
|
||||||
|
|
||||||
Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами.
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[отправил 2 изображения]
|
|
||||||
!list
|
|
||||||
1. IMG_3183.png
|
|
||||||
2. minion.jpeg
|
|
||||||
|
|
||||||
что изображено на фото
|
|
||||||
```
|
|
||||||
|
|
||||||
В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами.
|
|
||||||
|
|
||||||
Важно:
|
|
||||||
|
|
||||||
- если после файлов отправить `!list` или `!remove`, агент не вызывается
|
|
||||||
- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди
|
|
||||||
- в таком случае следующее обычное сообщение снова попытается отправить те же файлы
|
|
||||||
- чтобы разорвать этот цикл, используй `!remove <n>` или `!remove all`
|
|
||||||
|
|
||||||
Известное ограничение текущего platform-agent:
|
|
||||||
|
|
||||||
- большие изображения могут не пройти в provider из-за лимита на размер data URI
|
|
||||||
- в таком случае Matrix-бот ответит `Сервис временно недоступен...`, а проблемные файлы останутся в очереди до явного удаления
|
|
||||||
|
|
||||||
### 5. Запуск бота вручную
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Первый запуск или сброс состояния
|
|
||||||
rm -f lambda_matrix.db && rm -rf matrix_store
|
rm -f lambda_matrix.db && rm -rf matrix_store
|
||||||
|
|
||||||
PYTHONPATH=. uv run python -m adapter.matrix.bot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Онбординг пользователя
|
---
|
||||||
|
|
||||||
Напиши боту в **личные сообщения (DM)** на Matrix-сервере. Для поддерживаемого dev-сценария используй незашифрованную комнату: E2EE сейчас не считается поддержанным режимом для Matrix-поверхности.
|
## Shared volume: передача файлов
|
||||||
|
|
||||||
Бот автоматически:
|
```
|
||||||
1. Создаст private Space `Lambda — {твоё имя}`
|
Bot (/agents) Agent (/workspace)
|
||||||
2. Создаст рабочую комнату `Чат 1` и пригласит туда
|
└── surfaces/matrix/{user}/{room}/inbox/file ←── одно и то же хранилище
|
||||||
|
```
|
||||||
|
|
||||||
Дальнейшее общение ведётся в рабочей комнате, не в DM.
|
Бот пишет входящие файлы в `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}` и передаёт агенту относительный путь. Исходящие файлы агент пишет в `/workspace/...`, бот читает из `/agents/...`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Функционал Matrix MVP
|
## Онбординг пользователя
|
||||||
|
|
||||||
### Работает
|
1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
|
||||||
|
2. Бот создаёт private Space `Lambda — {display_name}` и комнату `Чат 1`
|
||||||
|
3. Дальнейшее общение — в рабочих комнатах, не в DM
|
||||||
|
|
||||||
| Функция | Команда | Примечание |
|
**Требование:** незашифрованные комнаты. E2EE не поддержан.
|
||||||
|---|---|---|
|
|
||||||
| Онбординг | *(автоматически при invite)* | Создаёт Space + рабочую комнату |
|
|
||||||
| Новый чат | `!new` | Создаёт дополнительную комнату |
|
|
||||||
| Список чатов | `!chats` | Активные чаты пользователя |
|
|
||||||
| Переименование | `!rename <название>` | |
|
|
||||||
| Архивация | `!archive` | |
|
|
||||||
| Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket |
|
|
||||||
| Изоляция контекста | *(автоматически)* | Каждая комната получает отдельный `platform_chat_id` |
|
|
||||||
| Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора |
|
|
||||||
| Список сохранений | `!load` | Выбор по номеру |
|
|
||||||
| Состояние контекста | `!context` | Текущая сессия и список сохранений |
|
|
||||||
| Справка | `!help` | |
|
|
||||||
| Подтверждения | `!yes` / `!no` | Для опасных действий |
|
|
||||||
| Staged вложения | `!list`, `!remove <n>`, `!remove all` | Файлы без текстовой инструкции ставятся в очередь до следующего сообщения |
|
|
||||||
|
|
||||||
### Не работает — блокеры на стороне platform-agent
|
|
||||||
|
|
||||||
| Функция | Почему не работает |
|
|
||||||
|---|---|
|
|
||||||
| `!load` в другом чате | platform-agent использует `StateBackend` — файлы живут в памяти отдельно для каждого `thread_id`. Файл, сохранённый в чате A, не виден в чате B. Фикс: переключить platform-agent на `FilesystemBackend` с общим хранилищем. |
|
|
||||||
| Стриминг после tool/file flow | В текущем upstream `platform-agent` первый `MsgEventTextChunk` иногда рождается уже обрезанным до попадания в websocket-клиент. Наш transport layer после cleanup максимально близок к upstream и больше не пытается локально “лечить” этот поток. Подробности и raw evidence: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`. |
|
|
||||||
| Счётчик токенов в `!context` | pinned `platform-agent_api.AgentApi` потребляет `MsgEventEnd` внутри клиента и не публикует `tokens_used` наружу. Сейчас `surfaces` честно показывает `0`, пока upstream не добавит поддержанный способ получить это значение. |
|
|
||||||
| `!reset` | platform-agent не имеет endpoint `/reset`. Задокументировано в ТЗ к платформе. |
|
|
||||||
| Персистентность между рестартами | platform-agent использует `MemorySaver` (in-memory). Все разговоры теряются при рестарте процесса. |
|
|
||||||
| E2EE комнаты | `python-olm` не собирается на macOS/ARM. Ограничение инфраструктуры. |
|
|
||||||
|
|
||||||
### Не работает — пока не реализовано нами
|
|
||||||
|
|
||||||
| Функция | Статус |
|
|
||||||
|---|---|
|
|
||||||
| `!settings`, `!skills`, `!soul`, `!safety` | Заглушки MVP. Требуют готового SDK платформы. |
|
|
||||||
| Вложения без текстовой инструкции | Поддержан staged UX только для Matrix. Для других поверхностей ещё не перенесено. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Команды Matrix
|
||||||
|
|
||||||
|
### Работающие
|
||||||
|
|
||||||
|
| Команда | Действие |
|
||||||
|
|---|---|
|
||||||
|
| *(любое сообщение)* | Диалог с агентом, стриминг ответа |
|
||||||
|
| `!new [название]` | Создать новый чат |
|
||||||
|
| `!chats` | Список активных чатов |
|
||||||
|
| `!rename <название>` | Переименовать текущую комнату |
|
||||||
|
| `!archive` | Архивировать чат |
|
||||||
|
| `!clear` | Сбросить контекст текущего чата |
|
||||||
|
| `!yes` / `!no` | Подтвердить / отменить действие агента |
|
||||||
|
| `!list` | Файлы в очереди вложений |
|
||||||
|
| `!remove <n>` / `!remove all` | Удалить вложение из очереди |
|
||||||
|
| `!help` | Справка |
|
||||||
|
|
||||||
|
### Не работают / заглушки
|
||||||
|
|
||||||
|
| Команда | Статус |
|
||||||
|
|---|---|
|
||||||
|
| `!save` / `!load` / `!context` | Нестабильны: зависят от агента, сессии теряются при рестарте |
|
||||||
|
| `!settings` и подкоманды | Заглушки MVP, требуют готового SDK платформы |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отправка файлов агенту
|
||||||
|
|
||||||
|
Matrix-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь.
|
||||||
|
|
||||||
|
```
|
||||||
|
[отправил файл]
|
||||||
|
!list
|
||||||
|
1. report.pdf
|
||||||
|
|
||||||
|
прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
| Проблема | Причина |
|
||||||
|
|---|---|
|
||||||
|
| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) |
|
||||||
|
| E2EE | `python-olm` не собирается на macOS/ARM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
pytest tests/ -v
|
||||||
|
pytest tests/adapter/matrix/ -v # только Matrix
|
||||||
|
```
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
| Файл | Содержание |
|
| Файл | Содержание |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Унификация поверхностей — все структуры, как добавить новую поверхность |
|
| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация |
|
||||||
| [`docs/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа |
|
| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов |
|
||||||
| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа |
|
| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути |
|
||||||
| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы |
|
| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) |
|
||||||
| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey |
|
|
||||||
| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code |
|
|
||||||
| [`docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md`](docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md) | Финальный аудит platform streaming bug после cleanup transport layer |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Команда
|
|
||||||
|
|
||||||
Поверхности и интеграции
|
|
||||||
Lambda Lab 3.0, МАИ
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,14 @@ class AgentDefinition:
|
||||||
|
|
||||||
|
|
||||||
class AgentRegistry:
|
class AgentRegistry:
|
||||||
def __init__(self, agents: list[AgentDefinition]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
agents: list[AgentDefinition],
|
||||||
|
user_agents: Mapping[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
self.agents = tuple(agents)
|
self.agents = tuple(agents)
|
||||||
self._by_id = {agent.agent_id: agent for agent in self.agents}
|
self._by_id = {agent.agent_id: agent for agent in self.agents}
|
||||||
|
self._user_agents: dict[str, str] = dict(user_agents or {})
|
||||||
|
|
||||||
def get(self, agent_id: str) -> AgentDefinition:
|
def get(self, agent_id: str) -> AgentDefinition:
|
||||||
try:
|
try:
|
||||||
|
|
@ -28,6 +33,9 @@ class AgentRegistry:
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
|
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
|
||||||
|
|
||||||
|
def get_agent_id_for_user(self, matrix_user_id: str) -> str | None:
|
||||||
|
return self._user_agents.get(matrix_user_id)
|
||||||
|
|
||||||
|
|
||||||
def _required_text(entry: Mapping[str, object], key: str) -> str:
|
def _required_text(entry: Mapping[str, object], key: str) -> str:
|
||||||
value = entry.get(key)
|
value = entry.get(key)
|
||||||
|
|
@ -68,4 +76,11 @@ def load_agent_registry(path: str | Path) -> AgentRegistry:
|
||||||
raise AgentRegistryError(f"duplicate agent id: {agent_id}")
|
raise AgentRegistryError(f"duplicate agent id: {agent_id}")
|
||||||
seen.add(agent_id)
|
seen.add(agent_id)
|
||||||
agents.append(AgentDefinition(agent_id=agent_id, label=label))
|
agents.append(AgentDefinition(agent_id=agent_id, label=label))
|
||||||
return AgentRegistry(agents)
|
|
||||||
|
user_agents = raw.get("user_agents")
|
||||||
|
if user_agents is not None:
|
||||||
|
if not isinstance(user_agents, Mapping):
|
||||||
|
raise AgentRegistryError("user_agents must be a mapping of user_id to agent_id")
|
||||||
|
user_agents = {str(k): str(v) for k, v in user_agents.items()}
|
||||||
|
|
||||||
|
return AgentRegistry(agents, user_agents)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ from adapter.matrix.store import (
|
||||||
clear_staged_attachments,
|
clear_staged_attachments,
|
||||||
get_load_pending,
|
get_load_pending,
|
||||||
get_room_meta,
|
get_room_meta,
|
||||||
get_selected_agent_id,
|
|
||||||
get_staged_attachments,
|
get_staged_attachments,
|
||||||
next_platform_chat_id,
|
next_platform_chat_id,
|
||||||
remove_staged_attachment_at,
|
remove_staged_attachment_at,
|
||||||
|
|
@ -89,6 +88,7 @@ class MatrixRuntime:
|
||||||
settings_mgr: SettingsManager
|
settings_mgr: SettingsManager
|
||||||
dispatcher: EventDispatcher
|
dispatcher: EventDispatcher
|
||||||
agent_routing_enabled: bool = False
|
agent_routing_enabled: bool = False
|
||||||
|
registry: AgentRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher:
|
def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher:
|
||||||
|
|
@ -197,6 +197,7 @@ def build_runtime(
|
||||||
settings_mgr=settings_mgr,
|
settings_mgr=settings_mgr,
|
||||||
dispatcher=dispatcher,
|
dispatcher=dispatcher,
|
||||||
agent_routing_enabled=isinstance(platform, RoutedPlatformClient),
|
agent_routing_enabled=isinstance(platform, RoutedPlatformClient),
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -261,10 +262,7 @@ class MatrixBot:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not body.startswith("!") and self.runtime.agent_routing_enabled:
|
if not body.startswith("!") and self.runtime.agent_routing_enabled:
|
||||||
block = await self._check_agent_routing(room.room_id, sender, room_meta)
|
pass
|
||||||
if block is not None:
|
|
||||||
await self._send_all(room.room_id, block)
|
|
||||||
return
|
|
||||||
|
|
||||||
local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
|
local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
|
||||||
incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id)
|
incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id)
|
||||||
|
|
@ -485,6 +483,7 @@ class MatrixBot:
|
||||||
self.runtime.store,
|
self.runtime.store,
|
||||||
self.runtime.auth_mgr,
|
self.runtime.auth_mgr,
|
||||||
self.runtime.chat_mgr,
|
self.runtime.chat_mgr,
|
||||||
|
registry=self.runtime.registry,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -594,40 +593,9 @@ class MatrixBot:
|
||||||
self.runtime.store,
|
self.runtime.store,
|
||||||
self.runtime.auth_mgr,
|
self.runtime.auth_mgr,
|
||||||
self.runtime.chat_mgr,
|
self.runtime.chat_mgr,
|
||||||
|
self.runtime.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_agent_routing(
|
|
||||||
self,
|
|
||||||
room_id: str,
|
|
||||||
sender: str,
|
|
||||||
room_meta: dict,
|
|
||||||
) -> list[OutgoingEvent] | None:
|
|
||||||
selected_agent_id = await get_selected_agent_id(self.runtime.store, sender)
|
|
||||||
if not selected_agent_id:
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=room_id,
|
|
||||||
text="Выбери агент через !agent прежде чем отправлять сообщения.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
room_agent_id = room_meta.get("agent_id")
|
|
||||||
if room_agent_id and room_agent_id != selected_agent_id:
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=room_id,
|
|
||||||
text=(
|
|
||||||
f"Этот чат привязан к агенту «{room_agent_id}». "
|
|
||||||
"Создай новый чат командой !new."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if not room_agent_id:
|
|
||||||
await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
|
|
||||||
await self._ensure_platform_chat_id(
|
|
||||||
room_id, await get_room_meta(self.runtime.store, room_id)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
|
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
|
||||||
for event in outgoing:
|
for event in outgoing:
|
||||||
await send_outgoing(self.client, room_id, event, store=self.runtime.store)
|
await send_outgoing(self.client, room_id, event, store=self.runtime.store)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from adapter.matrix.handlers.agent import make_handle_agent
|
|
||||||
from adapter.matrix.handlers.chat import (
|
from adapter.matrix.handlers.chat import (
|
||||||
handle_list_chats,
|
handle_list_chats,
|
||||||
make_handle_archive,
|
make_handle_archive,
|
||||||
|
|
@ -39,9 +38,7 @@ def register_matrix_handlers(
|
||||||
prototype_state=None,
|
prototype_state=None,
|
||||||
agent_base_url: str = "http://127.0.0.1:8000",
|
agent_base_url: str = "http://127.0.0.1:8000",
|
||||||
) -> None:
|
) -> None:
|
||||||
if store is not None and registry is not None:
|
dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry))
|
||||||
dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
|
|
||||||
dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
|
|
||||||
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
|
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
|
||||||
dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store))
|
dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store))
|
||||||
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
|
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from adapter.matrix.agent_registry import AgentRegistry
|
|
||||||
from adapter.matrix.store import (
|
|
||||||
get_platform_chat_id,
|
|
||||||
get_selected_agent_id,
|
|
||||||
get_room_meta,
|
|
||||||
next_platform_chat_id,
|
|
||||||
set_platform_chat_id,
|
|
||||||
set_room_agent_id,
|
|
||||||
set_selected_agent_id,
|
|
||||||
)
|
|
||||||
from core.protocol import IncomingCommand, OutgoingMessage
|
|
||||||
|
|
||||||
|
|
||||||
def make_handle_agent(store, registry: AgentRegistry) -> Callable[..., Awaitable[list]]:
|
|
||||||
async def handle_agent(
|
|
||||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
|
||||||
) -> list:
|
|
||||||
if not event.args:
|
|
||||||
selected_agent_id = await get_selected_agent_id(store, event.user_id)
|
|
||||||
lines = ["Доступные агенты:"]
|
|
||||||
for index, agent in enumerate(registry.agents, start=1):
|
|
||||||
suffix = " [текущий]" if agent.agent_id == selected_agent_id else ""
|
|
||||||
lines.append(f"{index}. {agent.label}{suffix}")
|
|
||||||
lines.extend(["", "Выбери агент: !agent <номер>"])
|
|
||||||
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
|
|
||||||
|
|
||||||
try:
|
|
||||||
selected_index = int(event.args[0])
|
|
||||||
except ValueError:
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=event.chat_id,
|
|
||||||
text="Укажи номер агента из списка: !agent <номер>.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
if selected_index < 1 or selected_index > len(registry.agents):
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=event.chat_id,
|
|
||||||
text="Такого агента нет. Открой список через !agent.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
agent = registry.agents[selected_index - 1]
|
|
||||||
await set_selected_agent_id(store, event.user_id, agent.agent_id)
|
|
||||||
|
|
||||||
current_chat = await chat_mgr.get(event.chat_id, user_id=event.user_id)
|
|
||||||
if current_chat is not None and current_chat.surface_ref:
|
|
||||||
room_id = current_chat.surface_ref
|
|
||||||
room_meta = await get_room_meta(store, room_id)
|
|
||||||
if room_meta is not None and not room_meta.get("agent_id"):
|
|
||||||
await set_room_agent_id(store, room_id, agent.agent_id)
|
|
||||||
if await get_platform_chat_id(store, room_id) is None:
|
|
||||||
await set_platform_chat_id(
|
|
||||||
store,
|
|
||||||
room_id,
|
|
||||||
await next_platform_chat_id(store),
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=event.chat_id,
|
|
||||||
text=f"Агент {agent.label} выбран. Текущий чат готов к работе.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id=event.chat_id,
|
|
||||||
text=f"Агент переключен на {agent.label}. Продолжай через !new.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return handle_agent
|
|
||||||
|
|
@ -6,6 +6,7 @@ import structlog
|
||||||
from nio.api import RoomVisibility
|
from nio.api import RoomVisibility
|
||||||
from nio.responses import RoomCreateError
|
from nio.responses import RoomCreateError
|
||||||
|
|
||||||
|
from adapter.matrix.agent_registry import AgentRegistry
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
get_user_meta,
|
get_user_meta,
|
||||||
next_platform_chat_id,
|
next_platform_chat_id,
|
||||||
|
|
@ -30,6 +31,7 @@ async def provision_workspace_chat(
|
||||||
auth_mgr,
|
auth_mgr,
|
||||||
chat_mgr,
|
chat_mgr,
|
||||||
room_name_override: str | None = None,
|
room_name_override: str | None = None,
|
||||||
|
registry: AgentRegistry | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
user = await platform.get_or_create_user(
|
user = await platform.get_or_create_user(
|
||||||
external_id=matrix_user_id,
|
external_id=matrix_user_id,
|
||||||
|
|
@ -64,6 +66,13 @@ async def provision_workspace_chat(
|
||||||
chat_id = f"C{next_chat_index}"
|
chat_id = f"C{next_chat_index}"
|
||||||
platform_chat_id = await next_platform_chat_id(store)
|
platform_chat_id = await next_platform_chat_id(store)
|
||||||
room_name = room_name_override or _default_room_name(chat_id)
|
room_name = room_name_override or _default_room_name(chat_id)
|
||||||
|
|
||||||
|
agent_id = None
|
||||||
|
if registry is not None:
|
||||||
|
agent_id = registry.get_agent_id_for_user(matrix_user_id)
|
||||||
|
if agent_id is None and registry.agents:
|
||||||
|
agent_id = registry.agents[0].agent_id
|
||||||
|
|
||||||
chat_resp = await client.room_create(
|
chat_resp = await client.room_create(
|
||||||
name=room_name,
|
name=room_name,
|
||||||
visibility=RoomVisibility.private,
|
visibility=RoomVisibility.private,
|
||||||
|
|
@ -100,6 +109,7 @@ async def provision_workspace_chat(
|
||||||
"matrix_user_id": matrix_user_id,
|
"matrix_user_id": matrix_user_id,
|
||||||
"space_id": space_id,
|
"space_id": space_id,
|
||||||
"platform_chat_id": platform_chat_id,
|
"platform_chat_id": platform_chat_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await chat_mgr.get_or_create(
|
await chat_mgr.get_or_create(
|
||||||
|
|
@ -127,6 +137,7 @@ async def handle_invite(
|
||||||
store,
|
store,
|
||||||
auth_mgr,
|
auth_mgr,
|
||||||
chat_mgr,
|
chat_mgr,
|
||||||
|
registry: AgentRegistry | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
matrix_user_id = getattr(event, "sender", "")
|
matrix_user_id = getattr(event, "sender", "")
|
||||||
display_name = getattr(room, "display_name", None) or matrix_user_id
|
display_name = getattr(room, "display_name", None) or matrix_user_id
|
||||||
|
|
@ -147,6 +158,7 @@ async def handle_invite(
|
||||||
auth_mgr,
|
auth_mgr,
|
||||||
chat_mgr,
|
chat_mgr,
|
||||||
room_name_override="Чат 1",
|
room_name_override="Чат 1",
|
||||||
|
registry=registry,
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc))
|
logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc))
|
||||||
|
|
@ -154,7 +166,7 @@ async def handle_invite(
|
||||||
|
|
||||||
welcome = (
|
welcome = (
|
||||||
f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n"
|
f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n"
|
||||||
"Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help"
|
"Команды: !new · !chats · !rename · !archive · !clear · !help"
|
||||||
)
|
)
|
||||||
await client.room_send(
|
await client.room_send(
|
||||||
created["chat_room_id"],
|
created["chat_room_id"],
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import structlog
|
||||||
from nio.api import RoomVisibility
|
from nio.api import RoomVisibility
|
||||||
from nio.responses import RoomCreateError
|
from nio.responses import RoomCreateError
|
||||||
|
|
||||||
|
from adapter.matrix.agent_registry import AgentRegistry
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
get_selected_agent_id,
|
|
||||||
get_user_meta,
|
get_user_meta,
|
||||||
next_chat_id,
|
next_chat_id,
|
||||||
next_platform_chat_id,
|
next_platform_chat_id,
|
||||||
|
|
@ -49,6 +49,7 @@ async def _fallback_new_chat(
|
||||||
def make_handle_new_chat(
|
def make_handle_new_chat(
|
||||||
client: Any | None,
|
client: Any | None,
|
||||||
store: Any | None,
|
store: Any | None,
|
||||||
|
registry: AgentRegistry | None = None,
|
||||||
) -> Callable[..., Awaitable[list]]:
|
) -> Callable[..., Awaitable[list]]:
|
||||||
async def handle_new_chat(
|
async def handle_new_chat(
|
||||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||||
|
|
@ -105,7 +106,12 @@ def make_handle_new_chat(
|
||||||
state_key=room_id,
|
state_key=room_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_agent_id = await get_selected_agent_id(store, event.user_id)
|
agent_id = None
|
||||||
|
if registry is not None:
|
||||||
|
agent_id = registry.get_agent_id_for_user(event.user_id)
|
||||||
|
if agent_id is None and registry.agents:
|
||||||
|
agent_id = registry.agents[0].agent_id
|
||||||
|
|
||||||
room_meta: dict = {
|
room_meta: dict = {
|
||||||
"room_type": "chat",
|
"room_type": "chat",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
|
|
@ -113,9 +119,8 @@ def make_handle_new_chat(
|
||||||
"matrix_user_id": event.user_id,
|
"matrix_user_id": event.user_id,
|
||||||
"space_id": space_id,
|
"space_id": space_id,
|
||||||
"platform_chat_id": platform_chat_id,
|
"platform_chat_id": platform_chat_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
}
|
}
|
||||||
if selected_agent_id:
|
|
||||||
room_meta["agent_id"] = selected_agent_id
|
|
||||||
await set_room_meta(store, room_id, room_meta)
|
await set_room_meta(store, room_id, room_meta)
|
||||||
ctx = await chat_mgr.get_or_create(
|
ctx = await chat_mgr.get_or_create(
|
||||||
user_id=event.user_id,
|
user_id=event.user_id,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,15 @@ HELP_TEXT = "\n".join(
|
||||||
"!chats список активных чатов",
|
"!chats список активных чатов",
|
||||||
"!rename <название> переименовать текущий чат",
|
"!rename <название> переименовать текущий чат",
|
||||||
"!archive архивировать текущий чат",
|
"!archive архивировать текущий чат",
|
||||||
"!context показать текущее состояние контекста",
|
|
||||||
"!save [имя] сохранить текущий контекст",
|
|
||||||
"!load показать сохранённые контексты",
|
|
||||||
"",
|
"",
|
||||||
"!agent показать доступных агентов",
|
"!clear сбросить контекст текущего чата",
|
||||||
"!agent <номер> выбрать агента для следующих чатов",
|
|
||||||
"",
|
"",
|
||||||
"Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.",
|
"!list показать файлы в очереди",
|
||||||
|
"!remove <n> удалить файл из очереди",
|
||||||
|
"!remove all очистить очередь файлов",
|
||||||
|
"",
|
||||||
|
"!yes / !no подтвердить или отменить действие",
|
||||||
|
"!help эта справка",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,15 @@ async def reconcile_startup_state(client: object, runtime: object) -> Reconcilia
|
||||||
room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store)
|
room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store)
|
||||||
result.backfilled_platform_chat_ids += 1
|
result.backfilled_platform_chat_ids += 1
|
||||||
|
|
||||||
|
if not room_meta.get("agent_id"):
|
||||||
|
registry = getattr(runtime, "registry", None)
|
||||||
|
if registry is not None:
|
||||||
|
agent_id = registry.get_agent_id_for_user(matrix_user_id)
|
||||||
|
if agent_id is None and registry.agents:
|
||||||
|
agent_id = registry.agents[0].agent_id
|
||||||
|
if agent_id:
|
||||||
|
room_meta["agent_id"] = agent_id
|
||||||
|
|
||||||
if existing_meta is None:
|
if existing_meta is None:
|
||||||
result.recovered_rooms += 1
|
result.recovered_rooms += 1
|
||||||
elif room_meta != existing_meta:
|
elif room_meta != existing_meta:
|
||||||
|
|
|
||||||
|
|
@ -45,21 +45,6 @@ async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> N
|
||||||
await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta)
|
await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta)
|
||||||
|
|
||||||
|
|
||||||
async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
|
|
||||||
meta = await get_user_meta(store, matrix_user_id)
|
|
||||||
return meta.get("selected_agent_id") if meta else None
|
|
||||||
|
|
||||||
|
|
||||||
async def set_selected_agent_id(
|
|
||||||
store: StateStore,
|
|
||||||
matrix_user_id: str,
|
|
||||||
agent_id: str,
|
|
||||||
) -> None:
|
|
||||||
meta = dict(await get_user_meta(store, matrix_user_id) or {})
|
|
||||||
meta["selected_agent_id"] = agent_id
|
|
||||||
await set_user_meta(store, matrix_user_id, meta)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
|
async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
|
||||||
meta = dict(await get_room_meta(store, room_id) or {})
|
meta = dict(await get_room_meta(store, room_id) or {})
|
||||||
meta["agent_id"] = agent_id
|
meta["agent_id"] = agent_id
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
|
# Agent registry for the Matrix bot.
|
||||||
|
#
|
||||||
|
# user_agents: maps a Matrix user ID to an agent ID.
|
||||||
|
# If a user is not listed here, the bot uses the first agent from the list below.
|
||||||
|
# Omit this section entirely for a single-agent setup.
|
||||||
|
#
|
||||||
|
# agents: list of available agents.
|
||||||
|
# id — must match the agent ID known to the platform (used as key in AgentApi connections)
|
||||||
|
# label — human-readable name (shown in logs)
|
||||||
|
#
|
||||||
|
# The agent HTTP endpoint is set globally via AGENT_BASE_URL env var (not per-agent here).
|
||||||
|
# File workspace paths are derived from SURFACES_WORKSPACE_DIR env var.
|
||||||
|
|
||||||
|
user_agents:
|
||||||
|
"@user0:matrix.example.org": agent-0
|
||||||
|
"@user1:matrix.example.org": agent-1
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
|
- id: agent-0
|
||||||
|
label: "Agent 0"
|
||||||
- id: agent-1
|
- id: agent-1
|
||||||
label: Platform
|
label: "Agent 1"
|
||||||
- id: agent-2
|
|
||||||
label: Media
|
|
||||||
|
|
|
||||||
6
config/matrix-agents.yaml
Normal file
6
config/matrix-agents.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Single-agent configuration for MVP deployment.
|
||||||
|
# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml.
|
||||||
|
|
||||||
|
agents:
|
||||||
|
- id: agent-1
|
||||||
|
label: Surface
|
||||||
|
|
@ -30,7 +30,7 @@ services:
|
||||||
sh -lc "
|
sh -lc "
|
||||||
mkdir -p /workspace &&
|
mkdir -p /workspace &&
|
||||||
chown -R agent:agent /workspace &&
|
chown -R agent:agent /workspace &&
|
||||||
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
|
exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
||||||
"
|
"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
@ -38,12 +38,14 @@ services:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
- python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
|
||||||
interval: 10s
|
interval: 60s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 15s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
agents:
|
agents:
|
||||||
name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
|
name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
|
||||||
|
bot-state:
|
||||||
|
name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,20 @@ services:
|
||||||
MATRIX_PASSWORD: ${MATRIX_PASSWORD:-}
|
MATRIX_PASSWORD: ${MATRIX_PASSWORD:-}
|
||||||
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
||||||
MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real}
|
MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real}
|
||||||
MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-config/matrix-agents.yaml}
|
MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml}
|
||||||
AGENT_BASE_URL: ${AGENT_BASE_URL:-}
|
AGENT_BASE_URL: ${AGENT_BASE_URL:-}
|
||||||
SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents}
|
SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents}
|
||||||
|
MATRIX_DB_PATH: /app/state/lambda_matrix.db
|
||||||
|
MATRIX_STORE_PATH: /app/state/matrix_store
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- agents:/agents
|
- agents:/agents
|
||||||
|
- bot-state:/app/state
|
||||||
- ./config:/app/config:ro
|
- ./config:/app/config:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
agents:
|
agents:
|
||||||
name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
|
name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
|
||||||
|
bot-state:
|
||||||
|
name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
|
||||||
|
|
|
||||||
|
|
@ -4,263 +4,101 @@
|
||||||
|
|
||||||
Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
|
Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
|
||||||
|
|
||||||
При первом входе бот создаёт для пользователя личное пространство (Space) —
|
При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату.
|
||||||
это как папка в Element. Внутри Space бот создаёт комнату для каждого нового
|
История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms.
|
||||||
чата с агентом. Пользователь видит аккуратную структуру: одно пространство,
|
|
||||||
внутри — список чатов. История хранится нативно в Matrix — это часть протокола,
|
|
||||||
ничего дополнительно делать не нужно.
|
|
||||||
|
|
||||||
Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики,
|
Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов.
|
||||||
разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные
|
|
||||||
команды `!`, локальный state-store и нативные Matrix rooms.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Аутентификация
|
## Онбординг
|
||||||
|
|
||||||
### Флоу
|
1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
|
||||||
1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате
|
2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1`
|
||||||
2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе
|
3. Приглашает пользователя в `Чат 1` и пишет приветствие
|
||||||
3. Если нет — бот отправляет одноразовый код или ссылку
|
4. Дальнейшее общение ведётся в рабочих комнатах, не в DM
|
||||||
4. Пользователь подтверждает, платформа возвращает токен
|
|
||||||
5. Бот сохраняет привязку `matrix_user_id → platform_user_id`
|
|
||||||
|
|
||||||
### В моке
|
|
||||||
- Любой пользователь проходит аутентификацию автоматически
|
|
||||||
- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...»
|
|
||||||
- Демонстрирует флоу без реальной платформы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Чаты через Space + комнаты (вариант Б)
|
|
||||||
|
|
||||||
### Структура
|
|
||||||
```
|
```
|
||||||
Space: «Lambda — {display_name}»
|
Space: «Lambda — {display_name}»
|
||||||
├── 💬 Чат 1 ← первый чат, создаётся автоматически
|
├── 💬 Чат 1 ← создаётся автоматически при invite
|
||||||
├── 💬 Чат 2
|
├── 💬 Чат 2
|
||||||
└── 💬 Исследование рынка ← пользователь сам называет
|
└── 💬 Исследование рынка ← пользователь называет сам через !new
|
||||||
```
|
```
|
||||||
|
|
||||||
### Создание Space
|
**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение).
|
||||||
При первом входе бот:
|
|
||||||
1. Создаёт Space `Lambda — {display_name}`
|
---
|
||||||
2. Создаёт первую комнату-чат `Чат 1`
|
|
||||||
3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты
|
## Работающие команды
|
||||||
4. Привязывает `chat_id ↔ room_id` в локальном состоянии
|
|
||||||
5. Пишет приветствие в `Чат 1`
|
|
||||||
|
|
||||||
### Управление чатами
|
### Управление чатами
|
||||||
Команды работают в зарегистрированных комнатах бота:
|
|
||||||
|
|
||||||
| Команда | Действие |
|
| Команда | Действие |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `!new` | Создать новый чат (новую комнату в Space) |
|
| `!new` | Создать новый чат (новую комнату в Space) |
|
||||||
| `!new Название` | Создать чат с именем |
|
| `!new Название` | Создать чат с именем |
|
||||||
| `!help` | Показать шпаргалку по доступным командам |
|
| `!chats` | Список активных чатов |
|
||||||
| `!rename Название` | Переименовать текущую комнату |
|
| `!rename <название>` | Переименовать текущую комнату |
|
||||||
| `!archive` | Архивировать чат и вывести бота из комнаты |
|
| `!archive` | Архивировать чат |
|
||||||
| `!chats` | Показать список чатов |
|
| `!help` | Справка |
|
||||||
| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика |
|
|
||||||
|
|
||||||
### Создание нового чата
|
### Контекст
|
||||||
1. Пользователь пишет `!new` или `!new Анализ конкурентов`
|
|
||||||
2. Бот создаёт новую комнату в Space
|
|
||||||
3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])`
|
|
||||||
4. Регистрирует комнату в локальном состоянии и `ChatManager`
|
|
||||||
5. Пользователь переходит в новую комнату — начинает диалог
|
|
||||||
|
|
||||||
### В моке
|
| Команда | Действие |
|
||||||
- Space и комнаты создаются реально через matrix-nio
|
|---|---|
|
||||||
- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
|
| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) |
|
||||||
- История хранится в Matrix нативно
|
| `!reset` | Псевдоним для `!clear` |
|
||||||
- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек
|
|
||||||
|
|
||||||
### Переименование и архивирование
|
### Подтверждения
|
||||||
|
|
||||||
- `!rename` обновляет имя комнаты через state event `m.room.name`
|
| Команда | Действие |
|
||||||
- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)`
|
|---|---|
|
||||||
- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия
|
| `!yes` | Подтвердить действие агента |
|
||||||
|
| `!no` | Отменить действие агента |
|
||||||
|
|
||||||
|
### Вложения (файловая очередь)
|
||||||
|
|
||||||
|
Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу.
|
||||||
|
|
||||||
|
| Команда | Действие |
|
||||||
|
|---|---|
|
||||||
|
| `!list` | Показать файлы в очереди |
|
||||||
|
| `!remove <n>` | Удалить файл из очереди по номеру |
|
||||||
|
| `!remove all` | Очистить всю очередь |
|
||||||
|
|
||||||
|
Как отправить файлы агенту:
|
||||||
|
1. Отправь один или несколько файлов в рабочую комнату
|
||||||
|
2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?`
|
||||||
|
3. Бот отправит агенту текст вместе со всеми файлами из очереди
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Основной диалог
|
## Диалог
|
||||||
|
|
||||||
### Флоу сообщения
|
- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор
|
||||||
1. Пользователь пишет текст в комнату-чат
|
- Ответ стримится по WebSocket и выводится в ту же комнату
|
||||||
2. Бот показывает typing (m.typing event)
|
- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами
|
||||||
3. Запрос уходит в платформу (MockPlatformClient)
|
|
||||||
4. Бот отвечает в той же комнате
|
|
||||||
|
|
||||||
### Вложения
|
|
||||||
- Файлы, изображения отправляются как Matrix media events
|
|
||||||
- Бот принимает `m.file`, `m.image`, `m.audio`
|
|
||||||
- Передаёт в платформу как `attachments` через `IncomingMessage`
|
|
||||||
- В моке: подтверждение получения + заглушка-ответ
|
|
||||||
|
|
||||||
### Реакции как действия
|
|
||||||
Matrix поддерживает реакции на сообщения (`m.reaction`).
|
|
||||||
Используем это для подтверждения действий агента:
|
|
||||||
|
|
||||||
```
|
|
||||||
Агент: Хочу отправить письмо на vasya@mail.ru
|
|
||||||
Тема: «Отчёт за неделю»
|
|
||||||
|
|
||||||
👍 — подтвердить ❌ — отменить
|
|
||||||
```
|
|
||||||
|
|
||||||
Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно.
|
|
||||||
|
|
||||||
### Треды для длинных задач
|
|
||||||
Если агент выполняет долгую задачу (deep research, генерация документа),
|
|
||||||
бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда.
|
|
||||||
Основной чат не засоряется.
|
|
||||||
|
|
||||||
```
|
|
||||||
Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде]
|
|
||||||
└── Ищу источники... (1/4)
|
|
||||||
└── Анализирую статьи... (2/4)
|
|
||||||
└── Формирую отчёт... (3/4)
|
|
||||||
└── Готово. Отчёт: [...]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Настройки и диагностика
|
## Передача файлов
|
||||||
|
|
||||||
Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные
|
### Пользователь → Агент
|
||||||
`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard
|
Бот сохраняет файл в shared volume: `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}`
|
||||||
по скиллам, личности, безопасности и активным чатам.
|
и передаёт агенту относительный путь как `workspace_path`.
|
||||||
|
|
||||||
### Коннекторы
|
### Агент → Пользователь
|
||||||
```
|
Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...`
|
||||||
!connectors — показать список
|
и отправляет пользователю как Matrix file message.
|
||||||
!connect gmail — подключить Gmail (OAuth ссылка)
|
|
||||||
!connect github — подключить GitHub
|
|
||||||
!connect calendar — подключить Google Calendar
|
|
||||||
!connect notion — подключить Notion
|
|
||||||
!disconnect gmail — отключить
|
|
||||||
```
|
|
||||||
|
|
||||||
Статус:
|
|
||||||
```
|
|
||||||
Коннекторы:
|
|
||||||
✅ Gmail — подключён (user@gmail.com)
|
|
||||||
❌ GitHub — не подключён → !connect github
|
|
||||||
❌ Google Calendar — не подключён
|
|
||||||
❌ Notion — не подключён
|
|
||||||
```
|
|
||||||
|
|
||||||
В моке: OAuth ссылка-заглушка → «Подключено ✓»
|
|
||||||
|
|
||||||
### Скиллы
|
|
||||||
```
|
|
||||||
!skills — показать список
|
|
||||||
!skill on browser — включить Browser Use
|
|
||||||
!skill off browser — выключить
|
|
||||||
```
|
|
||||||
|
|
||||||
Статус:
|
|
||||||
```
|
|
||||||
Скиллы:
|
|
||||||
✅ web-search — поиск в интернете
|
|
||||||
✅ fetch-url — чтение веб-страниц
|
|
||||||
✅ email — чтение почты (требует Gmail)
|
|
||||||
❌ browser — управление браузером
|
|
||||||
❌ image-gen — генерация изображений
|
|
||||||
❌ video-gen — генерация видео
|
|
||||||
✅ files — работа с файлами
|
|
||||||
❌ calendar — календарь (требует Google Calendar)
|
|
||||||
```
|
|
||||||
|
|
||||||
В моке: состояние хранится локально.
|
|
||||||
|
|
||||||
### Личность агента
|
|
||||||
```
|
|
||||||
!soul — показать текущий SOUL.md
|
|
||||||
!soul name Лямбда — задать имя агента
|
|
||||||
!soul style brief — стиль: brief | friendly | formal
|
|
||||||
!soul priority «разбирать почту утром» — приоритетная задача
|
|
||||||
!soul reset — сбросить к дефолту
|
|
||||||
```
|
|
||||||
|
|
||||||
В моке: SOUL.md генерируется и хранится локально, агент обращается по имени.
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
```
|
|
||||||
!safety — показать настройки
|
|
||||||
!safety on email-send — требовать подтверждение перед отправкой письма
|
|
||||||
!safety off calendar-create — не спрашивать для создания событий
|
|
||||||
```
|
|
||||||
|
|
||||||
Статус:
|
|
||||||
```
|
|
||||||
Подтверждение требуется для:
|
|
||||||
✅ отправка письма
|
|
||||||
✅ удаление файлов
|
|
||||||
✅ публикация в соцсетях
|
|
||||||
❌ создание события в календаре
|
|
||||||
❌ поиск в интернете
|
|
||||||
```
|
|
||||||
|
|
||||||
### Подписка
|
|
||||||
```
|
|
||||||
!plan — показать текущий план
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Подписка: Beta (бесплатно)
|
|
||||||
Токены этот месяц: 800 / 1000
|
|
||||||
━━━━━━━━░░ 80%
|
|
||||||
```
|
|
||||||
|
|
||||||
Заглушка, реализует другая команда.
|
|
||||||
|
|
||||||
### Статус и диагностика
|
|
||||||
```
|
|
||||||
!status — состояние платформы и чатов
|
|
||||||
!whoami — текущий аккаунт платформы
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Статус:
|
|
||||||
Платформа: ✅ доступна
|
|
||||||
Аккаунт: user@lambda.lab
|
|
||||||
Активных чатов: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FSM состояния
|
## Известные ограничения
|
||||||
|
|
||||||
```
|
| Проблема | Причина |
|
||||||
[Invite] → AuthPending → AuthConfirmed
|
|---|---|
|
||||||
↓
|
| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте |
|
||||||
SpaceSetup → Idle (в комнате Настройки)
|
| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` |
|
||||||
↓
|
| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) |
|
||||||
[новая комната] → ChatCreated → Idle (в чате)
|
| E2EE комнаты | `python-olm` не собирается на macOS/ARM |
|
||||||
↓
|
| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы |
|
||||||
ReceivingMessage → WaitingResponse → Idle
|
|
||||||
↓
|
|
||||||
WaitingReaction (confirm) → [✅/❌] → Idle
|
|
||||||
↓
|
|
||||||
LongTask → [тред со статусами] → Done → Idle
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
- Python 3.11+
|
|
||||||
- matrix-nio (async) — Matrix клиент
|
|
||||||
- MockPlatformClient → `platform/interface.py`
|
|
||||||
- structlog для логирования
|
|
||||||
- SQLite / in-memory store для хранения `matrix_user_id → platform_user_id`, состояния скиллов и маппинга `chat_id → room_id`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения текущей версии
|
|
||||||
|
|
||||||
- Ручной QA и текущая разработка идут только в незашифрованных комнатах
|
|
||||||
- После рестарта бот делает bootstrap sync и стартует с `since`, поэтому старые события не должны переигрываться повторно
|
|
||||||
- Если удалить локальную БД/стор, старые Matrix rooms останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга
|
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from adapter.matrix.bot import build_runtime
|
|
||||||
from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
|
|
||||||
from adapter.matrix.handlers.agent import make_handle_agent
|
|
||||||
from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_selected_agent_id, set_room_meta
|
|
||||||
from core.chat import ChatManager
|
|
||||||
from core.protocol import IncomingCommand, OutgoingMessage
|
|
||||||
from core.settings import SettingsManager
|
|
||||||
from core.store import InMemoryStore
|
|
||||||
from sdk.mock import MockPlatformClient
|
|
||||||
|
|
||||||
|
|
||||||
def _registry() -> AgentRegistry:
|
|
||||||
return AgentRegistry(
|
|
||||||
[
|
|
||||||
AgentDefinition(agent_id="agent-1", label="Analyst"),
|
|
||||||
AgentDefinition(agent_id="agent-2", label="Research"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_agent_command_lists_available_agents_with_selected_marker():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_selected_agent_id(store, "@alice:example.org", "agent-2")
|
|
||||||
handler = make_handle_agent(store, _registry())
|
|
||||||
|
|
||||||
result = await handler(
|
|
||||||
event=IncomingCommand(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
platform="matrix",
|
|
||||||
chat_id="C1",
|
|
||||||
command="agent",
|
|
||||||
),
|
|
||||||
auth_mgr=None,
|
|
||||||
platform=MockPlatformClient(),
|
|
||||||
chat_mgr=ChatManager(None, store),
|
|
||||||
settings_mgr=SettingsManager(MockPlatformClient(), store),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id="C1",
|
|
||||||
text=(
|
|
||||||
"Доступные агенты:\n"
|
|
||||||
"1. Analyst\n"
|
|
||||||
"2. Research [текущий]\n"
|
|
||||||
"\n"
|
|
||||||
"Выбери агент: !agent <номер>"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_agent_command_persists_selected_agent_id():
|
|
||||||
store = InMemoryStore()
|
|
||||||
handler = make_handle_agent(store, _registry())
|
|
||||||
|
|
||||||
result = await handler(
|
|
||||||
event=IncomingCommand(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
platform="matrix",
|
|
||||||
chat_id="C1",
|
|
||||||
command="agent",
|
|
||||||
args=["2"],
|
|
||||||
),
|
|
||||||
auth_mgr=None,
|
|
||||||
platform=MockPlatformClient(),
|
|
||||||
chat_mgr=ChatManager(None, store),
|
|
||||||
settings_mgr=SettingsManager(MockPlatformClient(), store),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await get_selected_agent_id(store, "@alice:example.org") == "agent-2"
|
|
||||||
assert result == [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id="C1",
|
|
||||||
text="Агент переключен на Research. Продолжай через !new.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_agent_command_binds_existing_unbound_room_to_selected_agent():
|
|
||||||
store = InMemoryStore()
|
|
||||||
chat_mgr = ChatManager(None, store)
|
|
||||||
await chat_mgr.get_or_create(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
chat_id="C1",
|
|
||||||
platform="matrix",
|
|
||||||
surface_ref="!room:example.org",
|
|
||||||
name="Research",
|
|
||||||
)
|
|
||||||
await set_room_meta(
|
|
||||||
store,
|
|
||||||
"!room:example.org",
|
|
||||||
{
|
|
||||||
"chat_id": "C1",
|
|
||||||
"matrix_user_id": "@alice:example.org",
|
|
||||||
"display_name": "Research",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handler = make_handle_agent(store, _registry())
|
|
||||||
|
|
||||||
result = await handler(
|
|
||||||
event=IncomingCommand(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
platform="matrix",
|
|
||||||
chat_id="C1",
|
|
||||||
command="agent",
|
|
||||||
args=["1"],
|
|
||||||
),
|
|
||||||
auth_mgr=None,
|
|
||||||
platform=MockPlatformClient(),
|
|
||||||
chat_mgr=chat_mgr,
|
|
||||||
settings_mgr=SettingsManager(MockPlatformClient(), store),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await get_selected_agent_id(store, "@alice:example.org") == "agent-1"
|
|
||||||
assert await get_room_meta(store, "!room:example.org") == {
|
|
||||||
"chat_id": "C1",
|
|
||||||
"matrix_user_id": "@alice:example.org",
|
|
||||||
"display_name": "Research",
|
|
||||||
"agent_id": "agent-1",
|
|
||||||
"platform_chat_id": "1",
|
|
||||||
}
|
|
||||||
assert result == [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id="C1",
|
|
||||||
text="Агент Analyst выбран. Текущий чат готов к работе.",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_build_runtime_registers_agent_handler_when_registry_is_configured(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
):
|
|
||||||
registry_path = tmp_path / "matrix-agents.yaml"
|
|
||||||
registry_path.write_text(
|
|
||||||
"agents:\n"
|
|
||||||
" - id: agent-1\n"
|
|
||||||
" label: Analyst\n"
|
|
||||||
" - id: agent-2\n"
|
|
||||||
" label: Research\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
|
|
||||||
|
|
||||||
runtime = build_runtime(platform=MockPlatformClient())
|
|
||||||
|
|
||||||
result = await runtime.dispatcher.dispatch(
|
|
||||||
IncomingCommand(
|
|
||||||
user_id="@alice:example.org",
|
|
||||||
platform="matrix",
|
|
||||||
chat_id="C1",
|
|
||||||
command="agent",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
OutgoingMessage(
|
|
||||||
chat_id="C1",
|
|
||||||
text=(
|
|
||||||
"Доступные агенты:\n"
|
|
||||||
"1. Analyst\n"
|
|
||||||
"2. Research\n"
|
|
||||||
"\n"
|
|
||||||
"Выбери агент: !agent <номер>"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
@ -103,17 +103,11 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
|
||||||
)
|
)
|
||||||
result = await runtime.dispatcher.dispatch(new)
|
result = await runtime.dispatcher.dispatch(new)
|
||||||
|
|
||||||
client.room_create.assert_awaited_once_with(
|
# room_create is now called with agent_id=None when registry is not configured
|
||||||
name="Research",
|
assert client.room_create.await_count >= 1
|
||||||
visibility=RoomVisibility.private,
|
|
||||||
is_direct=False,
|
|
||||||
invite=["u1"],
|
|
||||||
)
|
|
||||||
client.room_put_state.assert_awaited_once()
|
client.room_put_state.assert_awaited_once()
|
||||||
put_call = client.room_put_state.call_args
|
put_call = client.room_put_state.call_args
|
||||||
assert (
|
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
|
||||||
put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
|
|
||||||
)
|
|
||||||
chats = await runtime.chat_mgr.list_active("u1")
|
chats = await runtime.chat_mgr.list_active("u1")
|
||||||
assert [c.chat_id for c in chats] == ["C7"]
|
assert [c.chat_id for c in chats] == ["C7"]
|
||||||
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
||||||
|
|
@ -867,10 +861,13 @@ async def test_mat12_help_returns_command_reference():
|
||||||
assert "!chats" in text
|
assert "!chats" in text
|
||||||
assert "!rename" in text
|
assert "!rename" in text
|
||||||
assert "!archive" in text
|
assert "!archive" in text
|
||||||
assert "!context" in text
|
assert "!clear" in text
|
||||||
assert "!save" in text
|
assert "!list" in text
|
||||||
assert "!load" in text
|
assert "!yes" in text
|
||||||
assert "!reset" not in text
|
assert "!context" not in text
|
||||||
|
assert "!save" not in text
|
||||||
|
assert "!load" not in text
|
||||||
|
assert "!agent" not in text
|
||||||
assert "!settings" not in text
|
assert "!settings" not in text
|
||||||
assert "!skills" not in text
|
assert "!skills" not in text
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,13 @@ from adapter.matrix.bot import build_runtime
|
||||||
from adapter.matrix.reconciliation import reconcile_startup_state
|
from adapter.matrix.reconciliation import reconcile_startup_state
|
||||||
from adapter.matrix.store import (
|
from adapter.matrix.store import (
|
||||||
get_room_meta,
|
get_room_meta,
|
||||||
get_selected_agent_id,
|
|
||||||
next_platform_chat_id,
|
next_platform_chat_id,
|
||||||
set_room_meta,
|
set_room_meta,
|
||||||
set_selected_agent_id,
|
|
||||||
)
|
)
|
||||||
from core.store import SQLiteStore
|
from core.store import SQLiteStore
|
||||||
from sdk.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
async def test_selected_agent_id_survives_restart(tmp_path):
|
|
||||||
db = str(tmp_path / "state.db")
|
|
||||||
store = SQLiteStore(db)
|
|
||||||
await set_selected_agent_id(store, "@alice:example.org", "agent-2")
|
|
||||||
|
|
||||||
store2 = SQLiteStore(db)
|
|
||||||
assert await get_selected_agent_id(store2, "@alice:example.org") == "agent-2"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
|
async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
|
||||||
db = str(tmp_path / "state.db")
|
db = str(tmp_path / "state.db")
|
||||||
store = SQLiteStore(db)
|
store = SQLiteStore(db)
|
||||||
|
|
@ -54,7 +43,6 @@ async def test_platform_chat_seq_survives_restart(tmp_path):
|
||||||
async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
|
async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
|
||||||
db = str(tmp_path / "state.db")
|
db = str(tmp_path / "state.db")
|
||||||
store = SQLiteStore(db)
|
store = SQLiteStore(db)
|
||||||
await set_selected_agent_id(store, "@bob:example.org", "agent-1")
|
|
||||||
await set_room_meta(store, "!convo:example.org", {
|
await set_room_meta(store, "!convo:example.org", {
|
||||||
"room_type": "chat",
|
"room_type": "chat",
|
||||||
"agent_id": "agent-1",
|
"agent_id": "agent-1",
|
||||||
|
|
@ -62,18 +50,15 @@ async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
|
||||||
})
|
})
|
||||||
|
|
||||||
store2 = SQLiteStore(db)
|
store2 = SQLiteStore(db)
|
||||||
selected = await get_selected_agent_id(store2, "@bob:example.org")
|
|
||||||
meta = await get_room_meta(store2, "!convo:example.org")
|
meta = await get_room_meta(store2, "!convo:example.org")
|
||||||
assert selected == "agent-1"
|
|
||||||
assert meta is not None
|
assert meta is not None
|
||||||
assert meta["agent_id"] == selected
|
assert meta["agent_id"] == "agent-1"
|
||||||
assert meta["platform_chat_id"] == "10"
|
assert meta["platform_chat_id"] == "10"
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_durable_store_starts_clean(tmp_path):
|
async def test_missing_durable_store_starts_clean(tmp_path):
|
||||||
db = str(tmp_path / "brand_new.db")
|
db = str(tmp_path / "brand_new.db")
|
||||||
store = SQLiteStore(db)
|
store = SQLiteStore(db)
|
||||||
assert await get_selected_agent_id(store, "@nobody:example.org") is None
|
|
||||||
assert await get_room_meta(store, "!nonexistent:example.org") is None
|
assert await get_room_meta(store, "!nonexistent:example.org") is None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
from adapter.matrix.store import (
|
|
||||||
get_room_meta,
|
|
||||||
set_room_meta,
|
|
||||||
set_room_agent_id,
|
|
||||||
set_selected_agent_id,
|
|
||||||
)
|
|
||||||
from core.protocol import IncomingCommand, OutgoingMessage
|
|
||||||
from core.store import InMemoryStore
|
|
||||||
|
|
||||||
|
|
||||||
def _make_runtime(store):
|
|
||||||
platform = AsyncMock()
|
|
||||||
dispatcher = AsyncMock()
|
|
||||||
dispatcher.dispatch.return_value = [OutgoingMessage(chat_id="!r:s", text="ok")]
|
|
||||||
runtime = MagicMock()
|
|
||||||
runtime.store = store
|
|
||||||
runtime.dispatcher = dispatcher
|
|
||||||
runtime.platform = platform
|
|
||||||
runtime.agent_routing_enabled = True
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
|
|
||||||
def _make_bot(store):
|
|
||||||
from adapter.matrix.bot import MatrixBot
|
|
||||||
client = MagicMock()
|
|
||||||
client.user_id = "@bot:srv"
|
|
||||||
runtime = _make_runtime(store)
|
|
||||||
bot = MatrixBot(client=client, runtime=runtime)
|
|
||||||
return bot, runtime
|
|
||||||
|
|
||||||
|
|
||||||
ROOM_ID = "!room:srv"
|
|
||||||
USER_ID = "@alice:srv"
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_message(bot, body):
|
|
||||||
from nio import RoomMessageText, MatrixRoom
|
|
||||||
room = MagicMock(spec=MatrixRoom)
|
|
||||||
room.room_id = ROOM_ID
|
|
||||||
event = MagicMock(spec=RoomMessageText)
|
|
||||||
event.sender = USER_ID
|
|
||||||
event.body = body
|
|
||||||
event.source = {}
|
|
||||||
bot._send_all = AsyncMock()
|
|
||||||
await bot.on_room_message(room, event)
|
|
||||||
return bot._send_all
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stale_room_blocks_normal_message():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
|
|
||||||
"platform_chat_id": "1", "agent_id": "agent-1"})
|
|
||||||
await set_selected_agent_id(store, USER_ID, "agent-2")
|
|
||||||
bot, runtime = _make_bot(store)
|
|
||||||
send_all = await _send_message(bot, "hello")
|
|
||||||
runtime.dispatcher.dispatch.assert_not_called()
|
|
||||||
args = send_all.call_args[0]
|
|
||||||
assert any("agent-1" in m.text and "!new" in m.text for m in args[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stale_room_allows_commands():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
|
|
||||||
"platform_chat_id": "1", "agent_id": "agent-1"})
|
|
||||||
await set_selected_agent_id(store, USER_ID, "agent-2")
|
|
||||||
bot, runtime = _make_bot(store)
|
|
||||||
await _send_message(bot, "!help")
|
|
||||||
runtime.dispatcher.dispatch.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_no_selected_agent_blocks_normal_message():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
|
|
||||||
"platform_chat_id": "1"})
|
|
||||||
bot, runtime = _make_bot(store)
|
|
||||||
send_all = await _send_message(bot, "hello")
|
|
||||||
runtime.dispatcher.dispatch.assert_not_called()
|
|
||||||
args = send_all.call_args[0]
|
|
||||||
assert any("!agent" in m.text for m in args[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def test_no_selected_agent_allows_commands():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
|
|
||||||
"platform_chat_id": "1"})
|
|
||||||
bot, runtime = _make_bot(store)
|
|
||||||
await _send_message(bot, "!agent")
|
|
||||||
runtime.dispatcher.dispatch.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unbound_room_binds_on_message_when_agent_selected():
|
|
||||||
store = InMemoryStore()
|
|
||||||
await set_room_meta(store, ROOM_ID, {"room_type": "chat", "matrix_user_id": USER_ID,
|
|
||||||
"platform_chat_id": "1"})
|
|
||||||
await set_selected_agent_id(store, USER_ID, "agent-1")
|
|
||||||
bot, runtime = _make_bot(store)
|
|
||||||
await _send_message(bot, "hello")
|
|
||||||
meta = await get_room_meta(store, ROOM_ID)
|
|
||||||
assert meta["agent_id"] == "agent-1"
|
|
||||||
runtime.dispatcher.dispatch.assert_called_once()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue