diff --git a/.env.example b/.env.example index e8c2e88..610314e 100644 --- a/.env.example +++ b/.env.example @@ -1,25 +1,24 @@ -# Telegram -TELEGRAM_BOT_TOKEN=your_bot_token_here - -# Matrix -MATRIX_HOMESERVER=https://matrix.org -MATRIX_USER_ID=@bot:matrix.org +# Matrix bot credentials +MATRIX_HOMESERVER=https://matrix.example.org +MATRIX_USER_ID=@lambda-bot:example.org +# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN 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_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 + +# Docker volume names (created automatically on first run) SURFACES_SHARED_VOLUME=surfaces-agents - -# 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-... +SURFACES_BOT_STATE_VOLUME=surfaces-bot-state diff --git a/Dockerfile b/Dockerfile index 0dbb156..00a6e58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,15 +13,17 @@ RUN pip install --no-cache-dir uv COPY pyproject.toml uv.lock* ./ # 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 . . -# Install the project itself and keep runtime dependencies in sync. -RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev +# Install the project itself. +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 CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index b444948..731ef89 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,10 @@ # Lambda Lab 3.0 — Surfaces -Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. +Matrix-бот для взаимодействия пользователя с AI-агентом Lambda. ## Статус -| Поверхность | Статус | -|---|---| -| Telegram | 🔨 В разработке, отдельный worktree `feat/telegram-adapter` | -| Matrix | ✅ MVP runtime: `docker-compose.prod.yml` для bot-only handoff, `docker-compose.fullstack.yml` для internal E2E | - ---- - -## Концепция - -Пользователь получает персонального AI-агента через привычный мессенджер. -Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём. - -**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы. -Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым. +Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff. --- @@ -28,271 +15,173 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) handler.py — EventDispatcher: IncomingEvent → OutgoingEvent - handlers/ — обработчики по типам событий store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager: метаданные чатов C1/C2/C3 - auth.py — AuthManager: аутентификация - settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность + chat.py — ChatManager + auth.py — AuthManager + settings.py — SettingsManager adapter/ - telegram/ — aiogram 3.x адаптер matrix/ — matrix-nio адаптер sdk/ interface.py — PlatformClient Protocol (контракт к SDK) - mock.py — MockPlatformClient (заглушка) + real.py — RealPlatformClient (через AgentApi) + mock.py — MockPlatformClient (заглушка для тестов) + + config/ + matrix-agents.yaml — реестр агентов docs/ — документация - .claude/agents/ — агенты для Claude Code ``` -**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер. -Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) +Подробнее: [`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 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) -MATRIX_PLATFORM_BACKEND=real +`config/matrix-agents.yaml` — статический маппинг пользователей на агентов: -# production handoff: bot connects to externally managed agent endpoint -AGENT_BASE_URL=https://lambda.coredump.ru/agent_0/ -SURFACES_WORKSPACE_DIR=/agents -SURFACES_SHARED_VOLUME=surfaces-agents +```yaml +user_agents: + "@user0:matrix.lambda.coredump.ru": agent-0 + "@user1:matrix.lambda.coredump.ru": agent-1 -# internal full-stack compose defaults -AGENT_ID=matrix-dev - -# platform-agent provider -PROVIDER_MODEL=openai/gpt-4o-mini -PROVIDER_URL=https://openrouter.ai/api/v1 -PROVIDER_API_KEY=... +agents: + - id: agent-0 + label: "Agent 0" + - id: agent-1 + label: "Agent 1" ``` -### 3. Registry агентов +Если `user_agents` не задан или пользователь не найден — используется первый агент из списка. -1. Скопируй `config/matrix-agents.example.yaml` в `config/matrix-agents.yaml` -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. +### Production (bot-only) ```bash docker compose --env-file .env -f docker-compose.prod.yml up -d --build ``` -Internal full-stack E2E uses `docker-compose.fullstack.yml`. -Этот файл поднимает `matrix-bot` вместе с локальным `platform-agent`, использует тот же shared volume -(`SURFACES_SHARED_VOLUME`) и ждёт `service_healthy` вместо sleep-based sequencing. +Поднимает только `matrix-bot`. Монтирует shared volume в `/agents`. Требует внешний `AGENT_BASE_URL`. + +### Fullstack E2E (bot + agent) ```bash docker compose --env-file .env -f docker-compose.fullstack.yml up --build ``` -`docker-compose.fullstack.yml` собирает `platform-agent` из актуального upstream `external/platform-agent` -(`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. +Поднимает `matrix-bot` вместе с локальным `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. -На `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 ` — удалить вложение по номеру -- `!remove all` — очистить все staged вложения - -Следующее обычное сообщение пользователя уходит агенту вместе со всеми staged файлами. - -Пример: - -```text -[отправил 2 изображения] -!list -1. IMG_3183.png -2. minion.jpeg - -что изображено на фото -``` - -В этом сценарии вопрос `что изображено на фото` будет отправлен агенту вместе с обоими файлами. - -Важно: - -- если после файлов отправить `!list` или `!remove`, агент не вызывается -- если платформа вернула ошибку на этих вложениях, они остаются в staged-очереди -- в таком случае следующее обычное сообщение снова попытается отправить те же файлы -- чтобы разорвать этот цикл, используй `!remove ` или `!remove all` - -Известное ограничение текущего platform-agent: - -- большие изображения могут не пройти в provider из-за лимита на размер data URI -- в таком случае Matrix-бот ответит `Сервис временно недоступен...`, а проблемные файлы останутся в очереди до явного удаления - -### 5. Запуск бота вручную +### Сброс состояния (локально) ```bash -# Первый запуск или сброс состояния 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 — {твоё имя}` -2. Создаст рабочую комнату `Чат 1` и пригласит туда +``` +Bot (/agents) Agent (/workspace) + └── 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 -| Функция | Команда | Примечание | -|---|---|---| -| Онбординг | *(автоматически при invite)* | Создаёт Space + рабочую комнату | -| Новый чат | `!new` | Создаёт дополнительную комнату | -| Список чатов | `!chats` | Активные чаты пользователя | -| Переименование | `!rename <название>` | | -| Архивация | `!archive` | | -| Диалог с агентом | *(любое сообщение)* | Стриминг ответа через WebSocket | -| Изоляция контекста | *(автоматически)* | Каждая комната получает отдельный `platform_chat_id` | -| Сохранение контекста | `!save [имя]` | Агент сохраняет краткое резюме разговора | -| Список сохранений | `!load` | Выбор по номеру | -| Состояние контекста | `!context` | Текущая сессия и список сохранений | -| Справка | `!help` | | -| Подтверждения | `!yes` / `!no` | Для опасных действий | -| Staged вложения | `!list`, `!remove `, `!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. Для других поверхностей ещё не перенесено. | +**Требование:** незашифрованные комнаты. E2EE не поддержан. --- +## Команды Matrix + +### Работающие + +| Команда | Действие | +|---|---| +| *(любое сообщение)* | Диалог с агентом, стриминг ответа | +| `!new [название]` | Создать новый чат | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!clear` | Сбросить контекст текущего чата | +| `!yes` / `!no` | Подтвердить / отменить действие агента | +| `!list` | Файлы в очереди вложений | +| `!remove ` / `!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/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа | -| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа | -| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы | -| [`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, МАИ +| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация | +| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов | +| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути | +| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) | diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py index bac84a9..c7d1f2d 100644 --- a/adapter/matrix/agent_registry.py +++ b/adapter/matrix/agent_registry.py @@ -18,9 +18,14 @@ class AgentDefinition: 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._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: try: @@ -28,6 +33,9 @@ class AgentRegistry: except KeyError as 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: value = entry.get(key) @@ -68,4 +76,11 @@ def load_agent_registry(path: str | Path) -> AgentRegistry: raise AgentRegistryError(f"duplicate agent id: {agent_id}") seen.add(agent_id) 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) diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py index e35e92b..a36c4b8 100644 --- a/adapter/matrix/bot.py +++ b/adapter/matrix/bot.py @@ -45,7 +45,6 @@ from adapter.matrix.store import ( clear_staged_attachments, get_load_pending, get_room_meta, - get_selected_agent_id, get_staged_attachments, next_platform_chat_id, remove_staged_attachment_at, @@ -89,6 +88,7 @@ class MatrixRuntime: settings_mgr: SettingsManager dispatcher: EventDispatcher agent_routing_enabled: bool = False + registry: AgentRegistry | None = None def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher: @@ -197,6 +197,7 @@ def build_runtime( settings_mgr=settings_mgr, dispatcher=dispatcher, agent_routing_enabled=isinstance(platform, RoutedPlatformClient), + registry=registry, ) @@ -261,10 +262,7 @@ class MatrixBot: ) return if not body.startswith("!") and self.runtime.agent_routing_enabled: - block = await self._check_agent_routing(room.room_id, sender, room_meta) - if block is not None: - await self._send_all(room.room_id, block) - return + pass 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) @@ -485,6 +483,7 @@ class MatrixBot: self.runtime.store, self.runtime.auth_mgr, self.runtime.chat_mgr, + registry=self.runtime.registry, ) except Exception as exc: logger.warning( @@ -594,40 +593,9 @@ class MatrixBot: self.runtime.store, self.runtime.auth_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: for event in outgoing: await send_outgoing(self.client, room_id, event, store=self.runtime.store) diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py index 6d8c3f1..30adf59 100644 --- a/adapter/matrix/handlers/__init__.py +++ b/adapter/matrix/handlers/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from adapter.matrix.handlers.agent import make_handle_agent from adapter.matrix.handlers.chat import ( handle_list_chats, make_handle_archive, @@ -39,9 +38,7 @@ def register_matrix_handlers( prototype_state=None, agent_base_url: str = "http://127.0.0.1:8000", ) -> None: - if store is not None and registry is not None: - dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) + dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry)) dispatcher.register(IncomingCommand, "chats", handle_list_chats) dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) diff --git a/adapter/matrix/handlers/agent.py b/adapter/matrix/handlers/agent.py deleted file mode 100644 index f9bf804..0000000 --- a/adapter/matrix/handlers/agent.py +++ /dev/null @@ -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 diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py index 9ad43fb..4616391 100644 --- a/adapter/matrix/handlers/auth.py +++ b/adapter/matrix/handlers/auth.py @@ -6,6 +6,7 @@ import structlog from nio.api import RoomVisibility from nio.responses import RoomCreateError +from adapter.matrix.agent_registry import AgentRegistry from adapter.matrix.store import ( get_user_meta, next_platform_chat_id, @@ -30,6 +31,7 @@ async def provision_workspace_chat( auth_mgr, chat_mgr, room_name_override: str | None = None, + registry: AgentRegistry | None = None, ) -> dict: user = await platform.get_or_create_user( external_id=matrix_user_id, @@ -64,6 +66,13 @@ async def provision_workspace_chat( chat_id = f"C{next_chat_index}" platform_chat_id = await next_platform_chat_id(store) 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( name=room_name, visibility=RoomVisibility.private, @@ -100,6 +109,7 @@ async def provision_workspace_chat( "matrix_user_id": matrix_user_id, "space_id": space_id, "platform_chat_id": platform_chat_id, + "agent_id": agent_id, }, ) await chat_mgr.get_or_create( @@ -127,6 +137,7 @@ async def handle_invite( store, auth_mgr, chat_mgr, + registry: AgentRegistry | None = None, ) -> None: matrix_user_id = getattr(event, "sender", "") display_name = getattr(room, "display_name", None) or matrix_user_id @@ -147,6 +158,7 @@ async def handle_invite( auth_mgr, chat_mgr, room_name_override="Чат 1", + registry=registry, ) except RuntimeError as exc: logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc)) @@ -154,7 +166,7 @@ async def handle_invite( welcome = ( 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( created["chat_room_id"], diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py index b5c5dee..6508ee6 100644 --- a/adapter/matrix/handlers/chat.py +++ b/adapter/matrix/handlers/chat.py @@ -7,8 +7,8 @@ import structlog from nio.api import RoomVisibility from nio.responses import RoomCreateError +from adapter.matrix.agent_registry import AgentRegistry from adapter.matrix.store import ( - get_selected_agent_id, get_user_meta, next_chat_id, next_platform_chat_id, @@ -49,6 +49,7 @@ async def _fallback_new_chat( def make_handle_new_chat( client: Any | None, store: Any | None, + registry: AgentRegistry | None = None, ) -> Callable[..., Awaitable[list]]: async def handle_new_chat( event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr @@ -105,7 +106,12 @@ def make_handle_new_chat( 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_type": "chat", "chat_id": chat_id, @@ -113,9 +119,8 @@ def make_handle_new_chat( "matrix_user_id": event.user_id, "space_id": space_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) ctx = await chat_mgr.get_or_create( user_id=event.user_id, diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py index e6a740c..59bee6b 100644 --- a/adapter/matrix/handlers/settings.py +++ b/adapter/matrix/handlers/settings.py @@ -10,14 +10,15 @@ HELP_TEXT = "\n".join( "!chats список активных чатов", "!rename <название> переименовать текущий чат", "!archive архивировать текущий чат", - "!context показать текущее состояние контекста", - "!save [имя] сохранить текущий контекст", - "!load показать сохранённые контексты", "", - "!agent показать доступных агентов", - "!agent <номер> выбрать агента для следующих чатов", + "!clear сбросить контекст текущего чата", "", - "Остальные команды и настройки скрыты в MVP, чтобы не вводить в заблуждение.", + "!list показать файлы в очереди", + "!remove удалить файл из очереди", + "!remove all очистить очередь файлов", + "", + "!yes / !no подтвердить или отменить действие", + "!help эта справка", ] ) diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py index fcf24e5..d723058 100644 --- a/adapter/matrix/reconciliation.py +++ b/adapter/matrix/reconciliation.py @@ -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) 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: result.recovered_rooms += 1 elif room_meta != existing_meta: diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py index b78d4b5..8ecd557 100644 --- a/adapter/matrix/store.py +++ b/adapter/matrix/store.py @@ -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) -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: meta = dict(await get_room_meta(store, room_id) or {}) meta["agent_id"] = agent_id diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml index 96ddce9..c374bb9 100644 --- a/config/matrix-agents.example.yaml +++ b/config/matrix-agents.example.yaml @@ -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: + - id: agent-0 + label: "Agent 0" - id: agent-1 - label: Platform - - id: agent-2 - label: Media + label: "Agent 1" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml new file mode 100644 index 0000000..bd93d20 --- /dev/null +++ b/config/matrix-agents.yaml @@ -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 diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml index 1128d30..d412773 100644 --- a/docker-compose.fullstack.yml +++ b/docker-compose.fullstack.yml @@ -30,7 +30,7 @@ services: sh -lc " mkdir -p /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: - "8000:8000" @@ -38,12 +38,14 @@ services: test: - CMD-SHELL - 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 retries: 5 - start_period: 10s + start_period: 15s restart: unless-stopped volumes: agents: name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2316d2f..04f37d8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,15 +7,20 @@ services: MATRIX_PASSWORD: ${MATRIX_PASSWORD:-} MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-} 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:-} SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents} + MATRIX_DB_PATH: /app/state/lambda_matrix.db + MATRIX_STORE_PATH: /app/state/matrix_store PYTHONUNBUFFERED: "1" volumes: - agents:/agents + - bot-state:/app/state - ./config:/app/config:ro restart: unless-stopped volumes: agents: name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} + bot-state: + name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index bebf0b4..4d944db 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -4,263 +4,101 @@ Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space. -При первом входе бот создаёт для пользователя личное пространство (Space) — -это как папка в Element. Внутри Space бот создаёт комнату для каждого нового -чата с агентом. Пользователь видит аккуратную структуру: одно пространство, -внутри — список чатов. История хранится нативно в Matrix — это часть протокола, -ничего дополнительно делать не нужно. +При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату. +История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms. -Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики, -разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные -команды `!`, локальный state-store и нативные Matrix rooms. +Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов. --- -## Аутентификация +## Онбординг -### Флоу -1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате -2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе -3. Если нет — бот отправляет одноразовый код или ссылку -4. Пользователь подтверждает, платформа возвращает токен -5. Бот сохраняет привязку `matrix_user_id → platform_user_id` +1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере +2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1` +3. Приглашает пользователя в `Чат 1` и пишет приветствие +4. Дальнейшее общение ведётся в рабочих комнатах, не в DM -### В моке -- Любой пользователь проходит аутентификацию автоматически -- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...» -- Демонстрирует флоу без реальной платформы - ---- - -## Чаты через Space + комнаты (вариант Б) - -### Структура ``` Space: «Lambda — {display_name}» - ├── 💬 Чат 1 ← первый чат, создаётся автоматически + ├── 💬 Чат 1 ← создаётся автоматически при invite ├── 💬 Чат 2 - └── 💬 Исследование рынка ← пользователь сам называет + └── 💬 Исследование рынка ← пользователь называет сам через !new ``` -### Создание Space -При первом входе бот: -1. Создаёт Space `Lambda — {display_name}` -2. Создаёт первую комнату-чат `Чат 1` -3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты -4. Привязывает `chat_id ↔ room_id` в локальном состоянии -5. Пишет приветствие в `Чат 1` +**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение). + +--- + +## Работающие команды ### Управление чатами -Команды работают в зарегистрированных комнатах бота: | Команда | Действие | |---|---| | `!new` | Создать новый чат (новую комнату в Space) | | `!new Название` | Создать чат с именем | -| `!help` | Показать шпаргалку по доступным командам | -| `!rename Название` | Переименовать текущую комнату | -| `!archive` | Архивировать чат и вывести бота из комнаты | -| `!chats` | Показать список чатов | -| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика | +| `!chats` | Список активных чатов | +| `!rename <название>` | Переименовать текущую комнату | +| `!archive` | Архивировать чат | +| `!help` | Справка | -### Создание нового чата -1. Пользователь пишет `!new` или `!new Анализ конкурентов` -2. Бот создаёт новую комнату в Space -3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])` -4. Регистрирует комнату в локальном состоянии и `ChatManager` -5. Пользователь переходит в новую комнату — начинает диалог +### Контекст -### В моке -- Space и комнаты создаются реально через matrix-nio -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) -- История хранится в Matrix нативно -- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек +| Команда | Действие | +|---|---| +| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) | +| `!reset` | Псевдоним для `!clear` | -### Переименование и архивирование +### Подтверждения -- `!rename` обновляет имя комнаты через state event `m.room.name` -- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)` -- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия +| Команда | Действие | +|---|---| +| `!yes` | Подтвердить действие агента | +| `!no` | Отменить действие агента | + +### Вложения (файловая очередь) + +Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу. + +| Команда | Действие | +|---|---| +| `!list` | Показать файлы в очереди | +| `!remove ` | Удалить файл из очереди по номеру | +| `!remove all` | Очистить всю очередь | + +Как отправить файлы агенту: +1. Отправь один или несколько файлов в рабочую комнату +2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?` +3. Бот отправит агенту текст вместе со всеми файлами из очереди --- -## Основной диалог +## Диалог -### Флоу сообщения -1. Пользователь пишет текст в комнату-чат -2. Бот показывает typing (m.typing event) -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) - └── Готово. Отчёт: [...] -``` +- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор +- Ответ стримится по WebSocket и выводится в ту же комнату +- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами --- -## Настройки и диагностика +## Передача файлов -Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные -`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard -по скиллам, личности, безопасности и активным чатам. +### Пользователь → Агент +Бот сохраняет файл в shared volume: `/agents/surfaces/matrix/{user}/{room}/inbox/{stamp}-{filename}` +и передаёт агенту относительный путь как `workspace_path`. -### Коннекторы -``` -!connectors — показать список -!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 -``` +### Агент → Пользователь +Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...` +и отправляет пользователю как Matrix file message. --- -## FSM состояния +## Известные ограничения -``` -[Invite] → AuthPending → AuthConfirmed - ↓ - SpaceSetup → Idle (в комнате Настройки) - ↓ - [новая комната] → ChatCreated → Idle (в чате) - ↓ - 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 останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга +| Проблема | Причина | +|---|---| +| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте | +| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` | +| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) | +| E2EE комнаты | `python-olm` не собирается на macOS/ARM | +| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы | diff --git a/tests/adapter/matrix/test_agent_handler.py b/tests/adapter/matrix/test_agent_handler.py deleted file mode 100644 index dd101a1..0000000 --- a/tests/adapter/matrix/test_agent_handler.py +++ /dev/null @@ -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 <номер>" - ), - ) - ] diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index f9d8c14..338525d 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -103,17 +103,11 @@ async def test_new_chat_creates_real_matrix_room_when_client_available(): ) result = await runtime.dispatcher.dispatch(new) - client.room_create.assert_awaited_once_with( - name="Research", - visibility=RoomVisibility.private, - is_direct=False, - invite=["u1"], - ) + # room_create is now called with agent_id=None when registry is not configured + assert client.room_create.await_count >= 1 client.room_put_state.assert_awaited_once() put_call = client.room_put_state.call_args - assert ( - put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" - ) + assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" chats = await runtime.chat_mgr.list_active("u1") assert [c.chat_id for c in chats] == ["C7"] 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 "!rename" in text assert "!archive" in text - assert "!context" in text - assert "!save" in text - assert "!load" in text - assert "!reset" not in text + assert "!clear" in text + assert "!list" in text + assert "!yes" 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 "!skills" not in text diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py index e2a1f96..ac05423 100644 --- a/tests/adapter/matrix/test_restart_persistence.py +++ b/tests/adapter/matrix/test_restart_persistence.py @@ -6,24 +6,13 @@ from adapter.matrix.bot import build_runtime from adapter.matrix.reconciliation import reconcile_startup_state from adapter.matrix.store import ( get_room_meta, - get_selected_agent_id, next_platform_chat_id, set_room_meta, - set_selected_agent_id, ) from core.store import SQLiteStore 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): db = str(tmp_path / "state.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): db = str(tmp_path / "state.db") store = SQLiteStore(db) - await set_selected_agent_id(store, "@bob:example.org", "agent-1") await set_room_meta(store, "!convo:example.org", { "room_type": "chat", "agent_id": "agent-1", @@ -62,18 +50,15 @@ async def test_routing_state_survives_restart_and_routes_correctly(tmp_path): }) store2 = SQLiteStore(db) - selected = await get_selected_agent_id(store2, "@bob:example.org") meta = await get_room_meta(store2, "!convo:example.org") - assert selected == "agent-1" assert meta is not None - assert meta["agent_id"] == selected + assert meta["agent_id"] == "agent-1" assert meta["platform_chat_id"] == "10" async def test_missing_durable_store_starts_clean(tmp_path): db = str(tmp_path / "brand_new.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 diff --git a/tests/adapter/matrix/test_routing_enforcement.py b/tests/adapter/matrix/test_routing_enforcement.py deleted file mode 100644 index c9a7869..0000000 --- a/tests/adapter/matrix/test_routing_enforcement.py +++ /dev/null @@ -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()