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:
Mikhail Putilovskij 2026-04-28 03:05:11 +03:00
parent 380961d6e9
commit b1aaa210a1
21 changed files with 311 additions and 937 deletions

View file

@ -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-...

View file

@ -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
View file

@ -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, МАИ

View file

@ -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)

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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"],

View file

@ -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,

View file

@ -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 эта справка",
] ]
) )

View file

@ -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:

View file

@ -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

View file

@ -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

View 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

View file

@ -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}

View file

@ -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}

View file

@ -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 останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга

View file

@ -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 <номер>"
),
)
]

View file

@ -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

View file

@ -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

View file

@ -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()